Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/server-upgrade/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ sudo DOWNLOAD_URL='https://github.com/hillsburg/BiSheng/releases/download/v0.1.0
3. 校验 SHA-256(若提供 `EXPECTED_SHA256`)
4. 解压到临时目录;归档当前程序文件为 `pre-upgrade-*.tar.gz`
5. 覆盖程序文件,**保留** `bisheng.db*`、`uploads/`、`appsettings.Production.json`、`data-protection-keys/`、`backup/`
6. `systemctl start` + 访问 `HEALTH_URL`(默认 `/admin/login`)
6. `systemctl start` + 访问 `HEALTH_URL`(默认 `/health`)
7. 失败时尝试用 `pre-upgrade-*.tar.gz` 回滚程序文件

## 与发版产物
Expand Down
4 changes: 2 additions & 2 deletions scripts/server-upgrade/upgrade-bisheng.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ set -euo pipefail

BISHENG_HOME="${BISHENG_HOME:-/opt/bisheng}"
SERVICE_NAME="${SERVICE_NAME:-bisheng}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8090/admin/login}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8090/health}"
SKIP_BACKUP="${SKIP_BACKUP:-0}"
SKIP_HEALTH="${SKIP_HEALTH:-0}"
EXPECTED_SHA256="${EXPECTED_SHA256:-}"
Expand Down Expand Up @@ -172,7 +172,7 @@ usage() {
SKIP_BACKUP=1 跳过备份(仅调试)
SKIP_HEALTH=1 跳过健康检查
SERVICE_NAME systemd 单元名(默认 bisheng)
HEALTH_URL 健康检查 URL(默认 http://127.0.0.1:8090/admin/login)
HEALTH_URL 健康检查 URL(默认 http://127.0.0.1:8090/health)
BACKUP_SCRIPT backup-bisheng.sh 路径
EOF
}
Expand Down
108 changes: 107 additions & 1 deletion src/BiSheng.Server/Auth/AdminPendingSessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,29 @@ public sealed class AdminPendingSessionService
/// <summary>初始化待完成会话 Cookie 名</summary>
public const string SetupCookieName = "bisheng.admin.pending-setup";

/// <summary>重绑 TOTP 待完成会话 Cookie 名</summary>
public const string TotpRebindCookieName = "bisheng.admin.pending-totp-rebind";

/// <summary>登录待会话默认有效期</summary>
public static readonly TimeSpan LoginPendingTimeToLive = TimeSpan.FromMinutes(5);

/// <summary>初始化待会话默认有效期</summary>
public static readonly TimeSpan SetupPendingTimeToLive = TimeSpan.FromMinutes(15);

/// <summary>重绑 TOTP 待会话默认有效期</summary>
public static readonly TimeSpan TotpRebindPendingTimeToLive = TimeSpan.FromMinutes(15);

private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

private readonly ITimeLimitedDataProtector _loginProtector;
private readonly ITimeLimitedDataProtector _setupProtector;
private readonly ITimeLimitedDataProtector _totpRebindProtector;
private readonly bool _secureAlways;
private readonly ConcurrentDictionary<string, SetupPendingState> _setupStates = new();
private readonly ConcurrentDictionary<string, TotpRebindPendingState> _totpRebindStates = new();

/// <summary>构造短时会话服务</summary>
public AdminPendingSessionService(
Expand All @@ -45,6 +53,8 @@ public AdminPendingSessionService(
.ToTimeLimitedDataProtector();
_setupProtector = dataProtection.CreateProtector("BiSheng.Admin.PendingSetup.v1")
.ToTimeLimitedDataProtector();
_totpRebindProtector = dataProtection.CreateProtector("BiSheng.Admin.PendingTotpRebind.v1")
.ToTimeLimitedDataProtector();
_secureAlways = configuration.GetValue("Cookies:SecureAlways", false);
}

Expand Down Expand Up @@ -163,6 +173,76 @@ public void ClearSetupPending(HttpContext httpContext)
httpContext.Response.Cookies.Delete(SetupCookieName, BuildDeleteOptions());
}

/// <summary>
/// 重绑 TOTP:密码(及旧码)验证通过后写入待完成会话;
/// Cookie 仅含 SessionId,新密钥明文仅存内存。
/// </summary>
public void SetTotpRebindPending(HttpContext httpContext, TotpRebindPendingState state)
{
CleanupExpiredTotpRebindStates();
var sessionId = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
state.ExpiresUtc = DateTime.UtcNow.Add(TotpRebindPendingTimeToLive);
_totpRebindStates[sessionId] = state;

var cookiePayload = new SetupPendingCookiePayload { SessionId = sessionId };
WriteCookie(
httpContext,
TotpRebindCookieName,
Protect(_totpRebindProtector, cookiePayload, TotpRebindPendingTimeToLive),
TotpRebindPendingTimeToLive);
}

/// <summary>读取并校验重绑 TOTP 待会话</summary>
public bool TryGetTotpRebindPending(HttpContext httpContext, out TotpRebindPendingState state)
{
state = default!;
if (!httpContext.Request.Cookies.TryGetValue(TotpRebindCookieName, out var cookie)
|| string.IsNullOrEmpty(cookie))
{
return false;
}

if (!TryUnprotect(_totpRebindProtector, cookie, out SetupPendingCookiePayload? data)
|| data == null
|| string.IsNullOrWhiteSpace(data.SessionId))
{
ClearTotpRebindPending(httpContext);
return false;
}

if (!_totpRebindStates.TryGetValue(data.SessionId, out var stored)
|| stored.ExpiresUtc < DateTime.UtcNow
|| stored.UserId == Guid.Empty
|| string.IsNullOrWhiteSpace(stored.TotpSecret))
{
if (!string.IsNullOrWhiteSpace(data.SessionId))
{
_totpRebindStates.TryRemove(data.SessionId, out _);
}

ClearTotpRebindPending(httpContext);
return false;
}

state = stored;
return true;
}

/// <summary>清除重绑 TOTP 待会话</summary>
public void ClearTotpRebindPending(HttpContext httpContext)
{
if (httpContext.Request.Cookies.TryGetValue(TotpRebindCookieName, out var cookie)
&& !string.IsNullOrEmpty(cookie)
&& TryUnprotect(_totpRebindProtector, cookie, out SetupPendingCookiePayload? data)
&& data != null
&& !string.IsNullOrWhiteSpace(data.SessionId))
{
_totpRebindStates.TryRemove(data.SessionId, out _);
}

httpContext.Response.Cookies.Delete(TotpRebindCookieName, BuildDeleteOptions());
}

/// <summary>加密并附加过期时间</summary>
private static string Protect<T>(ITimeLimitedDataProtector protector, T payload, TimeSpan ttl)
{
Expand Down Expand Up @@ -221,6 +301,19 @@ private void CleanupExpiredSetupStates()
}
}
}

/// <summary>清理过期的重绑 TOTP 内存态</summary>
private void CleanupExpiredTotpRebindStates()
{
var now = DateTime.UtcNow;
foreach (var pair in _totpRebindStates)
{
if (pair.Value.ExpiresUtc < now)
{
_totpRebindStates.TryRemove(pair.Key, out _);
}
}
}
}

/// <summary>登录第二步(TOTP)所需的短时载荷</summary>
Expand All @@ -233,7 +326,7 @@ public sealed class LoginPendingPayload
public string Username { get; set; } = string.Empty;
}

/// <summary>初始化 Cookie 载荷:仅不透明会话 ID</summary>
/// <summary>初始化 / 重绑 Cookie 载荷:仅不透明会话 ID</summary>
public sealed class SetupPendingCookiePayload
{
/// <summary>服务端内存会话键</summary>
Expand All @@ -258,3 +351,16 @@ public sealed class SetupPendingState
/// <summary>过期时间(UTC)</summary>
public DateTime ExpiresUtc { get; set; }
}

/// <summary>重绑 TOTP 第二步内存态</summary>
public sealed class TotpRebindPendingState
{
/// <summary>当前管理员用户 ID</summary>
public Guid UserId { get; set; }

/// <summary>新生成的 TOTP 明文密钥</summary>
public string TotpSecret { get; set; } = string.Empty;

/// <summary>过期时间(UTC)</summary>
public DateTime ExpiresUtc { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ public static IServiceCollection AddBiShengServerCore(
// 管理后台两步登录 / 初始化的短时加密会话
services.AddSingleton<AdminPendingSessionService>();
services.AddSingleton<TotpSecretProtector>();
services.Configure<ServerUpdateOptions>(
configuration.GetSection(ServerUpdateOptions.SectionName));
services.AddHttpClient<ServerUpdateCheckService>(client =>
{
client.Timeout = TimeSpan.FromSeconds(15);
});
services.AddSignalR();
services.AddProblemDetails();
services.AddControllers()
Expand Down
8 changes: 6 additions & 2 deletions src/BiSheng.Server/Middleware/SetupMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ public async Task InvokeAsync(HttpContext context, AppDbContext db)
return;
}

// 未初始化:只允许 /admin/setup 路径
// 未初始化:只允许 setup、健康检查与静态资源
var path = context.Request.Path.Value?.ToLower() ?? string.Empty;

if (path == "/admin/setup" || path.StartsWith("/_content") || path.StartsWith("/css") || path.StartsWith("/js"))
if (path == "/admin/setup"
|| path == "/health"
|| path.StartsWith("/_content")
|| path.StartsWith("/css")
|| path.StartsWith("/js"))
{
await _next(context);
return;
Expand Down
177 changes: 177 additions & 0 deletions src/BiSheng.Server/Pages/Account.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
@page "/admin/account"
@model AccountModel
@{
ViewData["Title"] = "账号安全";
}

<style>
.page-header {
margin-bottom: 28px;
animation: fadeUp 0.5s ease-out both;
}

.page-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 400;
letter-spacing: -0.03em;
margin-bottom: 4px;
}

.page-subtitle {
font-size: 14px;
color: var(--text-muted);
}

.section-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 32px 36px;
margin-bottom: 20px;
animation: fadeUp 0.5s ease-out both;
}

.section-card:nth-of-type(1) { animation-delay: 0.05s; }
.section-card:nth-of-type(2) { animation-delay: 0.1s; }

.section-title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 500;
margin-bottom: 6px;
}

.section-desc {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 20px;
line-height: 1.5;
}

.totp-section {
text-align: center;
margin: 8px 0 20px;
padding: 24px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}

.totp-section img {
display: block;
margin: 0 auto 16px;
border-radius: 8px;
background: #fff;
padding: 8px;
}

.totp-secret-text {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
word-break: break-all;
user-select: all;
}

.totp-input {
font-family: var(--font-mono);
font-size: 20px;
letter-spacing: 0.4em;
text-align: center;
}

.form-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
</style>

<div class="page-header">
<h1 class="page-title">账号安全</h1>
<p class="page-subtitle">@Model.Username · 修改密码与两步验证</p>
</div>

@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">@Model.SuccessMessage</div>
}

@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger">@Model.ErrorMessage</div>
}

<div class="section-card">
<h2 class="section-title">修改密码</h2>
<p class="section-desc">修改后下次登录使用新密码。不影响已签发的 API Key。</p>
<form method="post" asp-page-handler="ChangePassword">
<div class="form-group">
<label>当前密码</label>
<input asp-for="CurrentPassword" type="password" required autocomplete="current-password" />
</div>
<div class="form-group">
<label>新密码</label>
<input asp-for="NewPassword" type="password" required minlength="6" autocomplete="new-password" placeholder="至少 6 个字符" />
</div>
<div class="form-group">
<label>确认新密码</label>
<input asp-for="ConfirmNewPassword" type="password" required minlength="6" autocomplete="new-password" />
</div>
<button type="submit" class="btn btn-primary">更新密码</button>
</form>
</div>

<div class="section-card">
<h2 class="section-title">重绑两步验证(TOTP)</h2>
<p class="section-desc">
丢失 Authenticator 或更换手机时使用。完成后请删除 App 中的旧条目。
@(Model.IsDevelopment ? "开发环境无需输入当前验证码。" : "需验证当前密码与当前验证码。")
</p>

@if (Model.IsTotpRebindStep)
{
<div class="totp-section">
@if (!string.IsNullOrEmpty(Model.QrCodeUrl))
{
<img src="@Model.QrCodeUrl" alt="新 TOTP 二维码" width="180" height="180" />
}
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:8px;">用 Authenticator 扫描上方二维码,或手动输入:</p>
<div class="totp-secret-text">@Model.DisplayTotpSecret</div>
</div>

<form method="post" asp-page-handler="ConfirmTotpRebind">
<div class="form-group">
<label>新验证码</label>
<input asp-for="NewTotpCode" required autofocus maxlength="6" pattern="[0-9]{6}"
inputmode="numeric" autocomplete="one-time-code" class="totp-input"
placeholder="6 位数字" />
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">确认绑定</button>
<a href="/admin/account?resetTotp=1" class="btn btn-secondary">取消</a>
</div>
</form>
}
else
{
<form method="post" asp-page-handler="StartTotpRebind">
<div class="form-group">
<label>当前密码</label>
<input asp-for="TotpCurrentPassword" type="password" required autocomplete="current-password" />
</div>
@if (!Model.IsDevelopment)
{
<div class="form-group">
<label>当前验证码</label>
<input asp-for="CurrentTotpCode" required maxlength="6" pattern="[0-9]{6}"
inputmode="numeric" autocomplete="one-time-code" class="totp-input"
placeholder="6 位数字" />
</div>
}
<button type="submit" class="btn btn-primary">生成新二维码</button>
</form>
}
</div>
Loading
Loading