diff --git a/backend/cmd/smail-admin/main.go b/backend/cmd/smail-admin/main.go index 00c9b02..f5206d1 100644 --- a/backend/cmd/smail-admin/main.go +++ b/backend/cmd/smail-admin/main.go @@ -35,13 +35,7 @@ func run(args []string) int { fmt.Fprintln(os.Stderr, "超级管理员重置已拒绝:当前进程不是受信任的部署管理员") return 1 } - password, err := adminreset.ReadProtectedPassword() - if err != nil { - fmt.Fprintln(os.Stderr, "超级管理员重置已拒绝:", err) - return 1 - } - defer clearString(&password) - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() conn, err := adminreset.DialControl(ctx, adminreset.DefaultControlPath) if err != nil { @@ -49,7 +43,7 @@ func run(args []string) int { return 1 } defer conn.Close() - if err := adminreset.SendReset(ctx, conn, password); err != nil { + if err := adminreset.SendVerifiedReset(ctx, conn, adminreset.ReadProtectedInput, adminreset.ReadProtectedPassword); err != nil { fmt.Fprintln(os.Stderr, "超级管理员重置失败:", safeError(err)) return 1 } @@ -91,6 +85,15 @@ func safeError(err error) string { if err == nil { return "" } + if errors.Is(err, adminreset.ErrVerificationUnavailable) { + return "没有可用的安全验证方式,请先登录安全中心配置邮箱、手机动态码或安全码" + } + if errors.Is(err, adminreset.ErrVerificationFailed) { + return "安全验证失败、已过期或请求过于频繁,请稍后重新启动重置" + } + if errors.Is(err, adminreset.ErrExpiredSession) { + return "重置会话已过期,请重新启动重置" + } if errors.Is(err, adminreset.ErrInvalidRequest) || errors.Is(err, adminreset.ErrUnsupported) { return "请求无效或当前平台不支持" } diff --git a/backend/controllers/login.go b/backend/controllers/login.go index 9172576..9383af2 100644 --- a/backend/controllers/login.go +++ b/backend/controllers/login.go @@ -108,6 +108,8 @@ func loginSecurityFields(user *models.User) map[string]any { "showSecuritySetupPrompt": user != nil, "externalEmailConfigured": false, "webAuthnEnabled": webAuthnRuntime() != nil, + "totpConfigured": false, + "securitySetupRequired": false, } if user == nil { return fields @@ -118,18 +120,29 @@ func loginSecurityFields(user *models.User) map[string]any { fields["externalEmailConfigured"] = emailConfigured } } - configured := user.EffectiveRole() == access.RoleSuperAdmin - if !configured { - if service := credentialRuntime(); service != nil { - configured, _ = service.HasValidRecoveryCode(user.ID) - } - fields["recoveryCodesConfigured"] = configured - fields["showRecoverySetupPrompt"] = !configured + configured := false + if db.Instance != nil && user.ID > 0 { + configured, _ = db.Instance.Where("user_id = ? AND used_at IS NULL", user.ID).Exist(&models.RecoveryCode{}) + enabled, _ := db.Instance.Where("user_id = ? AND enabled = ? AND revoked_at IS NULL", user.ID, true).Exist(&models.TOTPAuthenticator{}) + fields["totpConfigured"] = enabled } + fields["recoveryCodesConfigured"] = configured + fields["showRecoverySetupPrompt"] = !configured fields["showSecuritySetupPrompt"] = !configured || fields["externalEmailConfigured"] != true + if user.EffectiveRole() == access.RoleSuperAdmin { + required := !configured && fields["externalEmailConfigured"] != true && fields["totpConfigured"] != true + fields["securitySetupRequired"] = required + fields["showSecuritySetupPrompt"] = required + fields["showRecoverySetupPrompt"] = false + } return fields } +// RequiresSecuritySetup 每次请求按当前凭据状态判断,撤销最后一种验证后立即恢复限制。 +func RequiresSecuritySetup(userID int, role access.Role) bool { + return loginSecurityFields(&models.User{ID: userID, Role: string(role)})["securitySetupRequired"] == true +} + func (limiter *loginLimiter) blocked(key string) bool { now := limiter.now() limiter.mu.Lock() diff --git a/backend/controllers/security.go b/backend/controllers/security.go index d6dc1d3..edf576f 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -246,9 +246,9 @@ type recoveryCodeRequest struct { Count int `json:"count"` } -func rejectSuperAdminRecoveryCodes(ctx *context.Context, w http.ResponseWriter) bool { - if ctx != nil && ctx.EffectiveRole() == access.RoleSuperAdmin { - writeSecurityError(w, response.NoAccessPrivileges, "超级管理员恢复码只能通过部署端专用通道管理") +func requireSuperAdminRecoveryCodeAuth(ctx *context.Context, w http.ResponseWriter) bool { + if ctx != nil && ctx.EffectiveRole() == access.RoleSuperAdmin && !ctx.RecentAuth { + writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先重新验证当前密码", string(access.CodeRecentAuthRequired)) return true } return false @@ -306,7 +306,7 @@ func GenerateRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *htt writeSecurityError(w, response.NeedLogin, "请先登录") return } - if rejectSuperAdminRecoveryCodes(ctx, w) { + if requireSuperAdminRecoveryCodeAuth(ctx, w) { return } var input recoveryCodeRequest @@ -339,9 +339,6 @@ func GetRecoveryCodeStatus(ctx *context.Context, w http.ResponseWriter, req *htt writeSecurityError(w, response.NeedLogin, "请先登录") return } - if rejectSuperAdminRecoveryCodes(ctx, w) { - return - } service := credentialRuntime() if service == nil { writeSecurityError(w, response.ServerError, "安全凭据服务不可用") @@ -361,7 +358,7 @@ func RevokeRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http. writeSecurityError(w, response.NeedLogin, "请先登录") return } - if rejectSuperAdminRecoveryCodes(ctx, w) { + if requireSuperAdminRecoveryCodeAuth(ctx, w) { return } service := credentialRuntime() @@ -512,6 +509,10 @@ func RequestVerification(ctx *context.Context, w http.ResponseWriter, req *http. if input.Account == "" { input.Account = input.Identifier } + if input.Purpose == verification.PurposeAdminLocalReset { + writeSecurityError(w, response.NoAccessPrivileges, "请使用本机部署重置通道") + return + } if !strings.EqualFold(strings.TrimSpace(input.Account), strings.TrimSpace(ctx.UserAccount)) { writeSecurityError(w, response.ParamsError, "请求无效") return @@ -547,6 +548,10 @@ func VerifyVerification(ctx *context.Context, w http.ResponseWriter, req *http.R if input.Account == "" { input.Account = input.Identifier } + if input.Purpose == verification.PurposeAdminLocalReset { + writeSecurityError(w, response.NoAccessPrivileges, "请使用本机部署重置通道") + return + } if !strings.EqualFold(strings.TrimSpace(input.Account), strings.TrimSpace(ctx.UserAccount)) { writeSecurityError(w, response.ParamsError, "验证失败") return diff --git a/backend/controllers/security_authentication_test.go b/backend/controllers/security_authentication_test.go index 20067f3..76285f2 100644 --- a/backend/controllers/security_authentication_test.go +++ b/backend/controllers/security_authentication_test.go @@ -56,7 +56,7 @@ func TestSecurityHandlersRejectUnauthenticatedRequests(t *testing.T) { } } -func TestSuperAdminCannotManageRecoveryCodesThroughWeb(t *testing.T) { +func TestSuperAdminRecoveryCodesRequireRecentAuthentication(t *testing.T) { ctx := &appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin} request := httptest.NewRequest(http.MethodPost, "/api/security/credentials/recovery-codes", strings.NewReader(`{"count":5}`)) recorder := httptest.NewRecorder() @@ -247,7 +247,6 @@ func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) { t.Fatalf("expected revealed smtp code %v, got %v", generated["code"], revealedMap["smtp"]) } - revokeRecorder := httptest.NewRecorder() revokeRequest := httptest.NewRequest( http.MethodPost, diff --git a/backend/controllers/security_setup.go b/backend/controllers/security_setup.go index a0cc3fa..7177264 100644 --- a/backend/controllers/security_setup.go +++ b/backend/controllers/security_setup.go @@ -30,6 +30,10 @@ func AcknowledgeSecuritySetup(ctx *context.Context, w http.ResponseWriter, req * writeSecurityError(w, response.ServerError, "保存提醒状态失败,请重试") return } + if RequiresSecuritySetup(user.ID, user.EffectiveRole()) { + writeSecurityError(w, response.NoAccessPrivileges, "请先启用至少一种安全验证") + return + } session.Instance.Put(req.Context(), session.SecuritySetupDismissedKey, true) response.NewSuccessResponse(map[string]any{"showSecuritySetupPrompt": false}).FPrint(w) } diff --git a/backend/controllers/security_setup_test.go b/backend/controllers/security_setup_test.go index 8402244..add9601 100644 --- a/backend/controllers/security_setup_test.go +++ b/backend/controllers/security_setup_test.go @@ -62,7 +62,7 @@ func setupSecurityPromptTest(t *testing.T) *xorm.Engine { previous := db.Instance db.Instance = engine t.Cleanup(func() { db.Instance = previous; _ = engine.Close() }) - if err := engine.Sync2(&models.User{}, &models.ExternalEmail{}, &models.RecoveryCode{}); err != nil { + if err := engine.Sync2(&models.User{}, &models.ExternalEmail{}, &models.RecoveryCode{}, &models.TOTPAuthenticator{}); err != nil { t.Fatal(err) } previousRuntime := securityRuntime @@ -81,7 +81,7 @@ func TestSecuritySetupAcknowledgementIsSessionScoped(t *testing.T) { session.Instance, config.Instance = previousSession, previousConfig config.SetRuntimeManager(previousManager) }) - first := models.User{Account: "root", Name: "Root", Role: string(access.RoleSuperAdmin)} + first := models.User{Account: "admin", Name: "Admin", Role: string(access.RoleAdmin)} second := models.User{Account: "alice", Name: "Alice", Role: string(access.RoleUser)} if _, err := engine.Insert(&first, &second); err != nil { t.Fatal(err) @@ -119,7 +119,7 @@ func TestSecuritySetupAcknowledgementIsSessionScoped(t *testing.T) { t.Fatal("dismissal must not persist on either account") } fields := loginSecurityFields(&loaded) - if fields["showSecuritySetupPrompt"] != true || fields["showRecoverySetupPrompt"] != false { + if fields["showSecuritySetupPrompt"] != true || fields["showRecoverySetupPrompt"] != true { t.Fatal("incomplete setup must be reported again on login") } if untouched.Password != second.Password || loaded.SecurityVersion != first.SecurityVersion { @@ -143,7 +143,7 @@ func TestSecuritySetupAcknowledgementIsSessionScoped(t *testing.T) { assertInfoPrompt := func(want bool) { t.Helper() recorder := httptest.NewRecorder() - Info(&appcontext.Context{UserID: first.ID, Role: access.RoleSuperAdmin}, recorder, request.WithContext(refreshed)) + Info(&appcontext.Context{UserID: first.ID, Role: access.RoleAdmin}, recorder, request.WithContext(refreshed)) var payload struct { ErrorNo int `json:"errorNo"` Data struct { @@ -188,8 +188,12 @@ func TestSecuritySetupRequiresUsableCredentials(t *testing.T) { } fields := loginSecurityFields(&user) emailReady := emailStatus == models.ExternalEmailStatusVerified - codeReady := codeStatus == "valid" || role == access.RoleSuperAdmin - if fields["externalEmailConfigured"] != emailReady || fields["showSecuritySetupPrompt"] != (!emailReady || !codeReady) { + codeReady := codeStatus == "valid" + wantPrompt := !emailReady || !codeReady + if role == access.RoleSuperAdmin { + wantPrompt = !emailReady && !codeReady + } + if fields["externalEmailConfigured"] != emailReady || fields["showSecuritySetupPrompt"] != wantPrompt { t.Fatalf("unexpected security state: %#v", fields) } }) @@ -209,6 +213,46 @@ func TestSecuritySetupRejectsAnonymous(t *testing.T) { } } +func TestSuperAdminCannotDismissRequiredSetupAndTOTPQualifies(t *testing.T) { + engine := setupSecurityPromptTest(t) + previous := session.Instance + session.Instance = scs.New() + t.Cleanup(func() { session.Instance = previous }) + user := models.User{Account: "root", Role: string(access.RoleSuperAdmin)} + if _, err := engine.Insert(&user); err != nil { + t.Fatal(err) + } + loaded, err := session.Instance.Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", nil).WithContext(loaded) + recorder := httptest.NewRecorder() + AcknowledgeSecuritySetup(&appcontext.Context{UserID: user.ID, Role: access.RoleSuperAdmin}, recorder, request) + var payload struct { + ErrorNo int `json:"errorNo"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload.ErrorNo == 0 { + t.Fatal("required setup was dismissed") + } + if session.Instance.GetBool(loaded, session.SecuritySetupDismissedKey) { + t.Fatal("dismissal persisted") + } + if _, err := engine.Insert(&models.TOTPAuthenticator{UserID: user.ID, Enabled: true}); err != nil { + t.Fatal(err) + } + if RequiresSecuritySetup(user.ID, user.EffectiveRole()) { + t.Fatal("enabled TOTP did not qualify") + } + now := time.Now() + if _, err := engine.Where("user_id = ?", user.ID).Cols("revoked_at").Update(&models.TOTPAuthenticator{RevokedAt: &now}); err != nil { + t.Fatal(err) + } + if !RequiresSecuritySetup(user.ID, user.EffectiveRole()) { + t.Fatal("revoked TOTP still qualifies") + } +} + func TestLegacyConfigStatusReportsUnavailable(t *testing.T) { previous := config.CurrentManager() config.SetRuntimeManager(nil) diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 7f29136..976d1ed 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -239,7 +239,7 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) { for key, value := range loginSecurityFields(&models.User{ID: ctx.UserID, Role: string(ctx.EffectiveRole())}) { result[key] = value } - if session.Instance != nil && session.Instance.GetBool(req.Context(), session.SecuritySetupDismissedKey) { + if result["securitySetupRequired"] != true && session.Instance != nil && session.Instance.GetBool(req.Context(), session.SecuritySetupDismissedKey) { result["showSecuritySetupPrompt"] = false result["showRecoverySetupPrompt"] = false } diff --git a/backend/listen/http_server/https_server.go b/backend/listen/http_server/https_server.go index 3b1acea..a70179e 100644 --- a/backend/listen/http_server/https_server.go +++ b/backend/listen/http_server/https_server.go @@ -205,6 +205,10 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc { response.NewErrorResponse(response.NoAccessPrivileges, "请先修改临时密码", "PASSWORD_CHANGE_REQUIRED").FPrint(w) return } + if ctx.UserID > 0 && ctx.MustChangePassword == 0 && ctx.EffectiveRole() == access.RoleSuperAdmin && !securitySetupAllowedPath(r.URL.Path, r.Method) && controllers.RequiresSecuritySetup(ctx.UserID, ctx.EffectiveRole()) { + response.NewErrorResponse(response.NoAccessPrivileges, "请先启用至少一种安全验证", response.ConfigErrorData{Code: "SECURITY_SETUP_REQUIRED"}).FPrint(w) + return + } } else if normalizedAPIPath(r.URL.Path) != "/api/setup" { response.NewErrorResponse(response.NeedSetup, "", "").FPrint(w) return @@ -235,6 +239,19 @@ func publicSecurityPath(path string) bool { return false } } + +func securitySetupAllowedPath(path, method string) bool { + switch normalizedAPIPath(path) { + case "/api/user/info", "/api/logout", "/api/security/reauth", "/api/security/setup/acknowledge", + "/api/security/totp", "/api/security/credentials/recovery-codes/generate", + "/api/security/external-email/bind", "/api/security/external-email/verify": + return true + case "/api/security/credentials/recovery-codes", "/api/security/external-email/list", "/api/security/credentials/question": + return method == http.MethodGet + default: + return false + } +} func credentialsQuestionHandler() http.HandlerFunc { get := contextIterceptor(controllers.GetSecurityQuestion) post := limitBody(16<<10, contextIterceptor(controllers.SetSecurityQuestion)) diff --git a/backend/listen/http_server/password_change_route_test.go b/backend/listen/http_server/password_change_route_test.go index 8f586c6..f45f8a0 100644 --- a/backend/listen/http_server/password_change_route_test.go +++ b/backend/listen/http_server/password_change_route_test.go @@ -87,6 +87,9 @@ func TestSuperAdminCannotChangeOwnPasswordWithoutEmailVerification(t *testing.T) if _, err := db.Instance.Insert(&superAdmin); err != nil { t.Fatal(err) } + if _, err := db.Instance.Insert(&models.RecoveryCode{UserID: superAdmin.ID, CodeHash: "fixture"}); err != nil { + t.Fatal(err) + } manager := scs.New() session.Instance = manager diff --git a/backend/listen/http_server/session_validation_test.go b/backend/listen/http_server/session_validation_test.go index bc20b33..eee315d 100644 --- a/backend/listen/http_server/session_validation_test.go +++ b/backend/listen/http_server/session_validation_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "time" @@ -61,6 +62,48 @@ func TestContextInterceptorValidatesCurrentUserState(t *testing.T) { } }) + t.Run("required security setup blocks business routes", func(t *testing.T) { + manager, loaded := sessionContext(t, &user) + session.Instance = manager + called := false + handler := contextIterceptor(controllers.HandlerFunc(func(_ *appcontext.Context, w http.ResponseWriter, _ *http.Request) { + called = true + response.NewSuccessResponse(nil).FPrint(w) + })) + for _, path := range []string{"/api/config/status", "/api/v1/config/status", "/api/v1/user/list"} { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil).WithContext(loaded)) + if called || !strings.Contains(recorder.Body.String(), "SECURITY_SETUP_REQUIRED") { + t.Fatalf("business route escaped gate: %s", recorder.Body.String()) + } + } + for _, path := range []string{"/api/v1/security/totp", "/api/v1/security/external-email/list", "/api/v1/security/credentials/recovery-codes"} { + called = false + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, path, nil).WithContext(loaded)) + if !called { + t.Fatalf("setup route blocked: %s", path) + } + } + code := models.RecoveryCode{UserID: user.ID, CodeHash: "fixture"} + if _, err := db.Instance.Insert(&code); err != nil { + t.Fatal(err) + } + called = false + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/v1/config/status", nil).WithContext(loaded)) + if !called { + t.Fatal("configured account blocked") + } + now := time.Now() + if _, err := db.Instance.ID(code.ID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { + t.Fatal(err) + } + called = false + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/v1/config/status", nil).WithContext(loaded)) + if called { + t.Fatal("revoked last method did not restore gate") + } + }) + t.Run("refresh does not elevate or renew authentication", func(t *testing.T) { for _, authenticatedAt := range []int64{0, time.Now().Add(-11 * time.Minute).Unix(), time.Now().Add(time.Hour).Unix()} { manager, loaded := sessionContext(t, &user) diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 4c09458..0b0b383 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -46,6 +46,7 @@ var adminResetControl adminreset.ControlServer var adminResetCancel context.CancelFunc var adminResetServeErrors chan error var verificationRuntime *verification.Runtime +var adminResetVerification *adminreset.VerificationService var credentialService *credentials.Service var webAuthnRuntimeService *webauthnservice.Service var logRuntime *logstore.Runtime @@ -373,6 +374,7 @@ func startVerificationRuntime() error { return fmt.Errorf("start TOTP service: %w", err) } controllers.ConfigureTOTPRuntime(totpService) + adminResetVerification = adminreset.NewVerificationService(db.Instance, external, credential, totpService, service, controllers.InvalidateSecurityRuntimeUser) controllers.ConfigureSecurityRuntime(service, external, recover, credential) credentialService = credential verificationRuntime = runtime @@ -462,9 +464,9 @@ func startAdminResetControlWithListener(development bool, path string, listen fu serveErrors := make(chan error, 1) adminResetServeErrors = serveErrors go func() { - err := control.Serve(ctx, func(password string) error { - return adminreset.ResetSuperAdmin(db.Instance, password, controllers.InvalidateSecurityRuntimeUser) - }, adminreset.NewSessionGuard(2*time.Minute)) + err := control.Serve(ctx, func() (adminreset.ResetSession, error) { + return adminResetVerification.Begin() + }, adminreset.NewSessionGuard(5*time.Minute)) serveErrors <- err if err != nil { log.WithError(err).WithFields(log.Fields{"action": "运行管理员恢复通道", "result": "failure"}).Error("管理员恢复通道运行失败") diff --git a/backend/services/adminreset/client.go b/backend/services/adminreset/client.go index 33b8a70..23226b9 100644 --- a/backend/services/adminreset/client.go +++ b/backend/services/adminreset/client.go @@ -3,17 +3,37 @@ package adminreset import ( "context" "encoding/json" + "errors" "fmt" "net" + "strconv" + "strings" "time" ) -func SendReset(ctx context.Context, conn net.Conn, password string) error { - if conn == nil || password == "" { +var ( + ErrVerificationUnavailable = errors.New("no verification method available") + ErrVerificationFailed = errors.New("verification failed") +) + +func responseError(code string) error { + switch code { + case "VERIFICATION_UNAVAILABLE": + return ErrVerificationUnavailable + case "VERIFICATION_FAILED": + return ErrVerificationFailed + case "REPLAY_OR_EXPIRED": + return ErrExpiredSession + default: return ErrInvalidRequest } - defer clearPassword(&password) - deadline := time.Now().Add(10 * time.Second) +} + +func SendVerifiedReset(ctx context.Context, conn net.Conn, input func(string) (string, error), readPassword func() (string, error)) error { + if conn == nil || input == nil || readPassword == nil { + return ErrInvalidRequest + } + deadline := time.Now().Add(controlRequestTimeout) if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { deadline = contextDeadline } @@ -22,10 +42,60 @@ func SendReset(ctx context.Context, conn net.Conn, password string) error { } defer conn.SetDeadline(time.Time{}) challenge, err := DecodeResponse(conn) - if err != nil || !challenge.OK || challenge.Code != "CHALLENGE" || challenge.Nonce == "" { + if err != nil { + return err + } + if !challenge.OK { + return responseError(challenge.Code) + } + if challenge.Code != "CHALLENGE" || challenge.Nonce == "" { return ErrInvalidRequest } - if err := json.NewEncoder(conn).Encode(Request{Version: ProtocolVersion, Nonce: challenge.Nonce, NewPassword: password}); err != nil { + var prompt strings.Builder + prompt.WriteString("请选择安全验证方式(输入序号;选择邮箱即发送验证码):") + for i, method := range challenge.Methods { + fmt.Fprintf(&prompt, " %d.%s", i+1, method.Label) + } + choice, err := input(prompt.String()) + if err != nil { + return err + } + index, err := strconv.Atoi(strings.TrimSpace(choice)) + if err != nil || index < 1 || index > len(challenge.Methods) { + return ErrInvalidRequest + } + exchange := func(request Request) error { + request.Version, request.Nonce = ProtocolVersion, challenge.Nonce + if err := json.NewEncoder(conn).Encode(request); err != nil { + return err + } + response, err := DecodeResponse(conn) + if err != nil { + return err + } + if !response.OK { + return responseError(response.Code) + } + return nil + } + if err := exchange(Request{Action: "select", Method: challenge.Methods[index-1].ID}); err != nil { + return err + } + code, err := input("请输入所选方式的验证码或安全码") + if err != nil { + return err + } + err = exchange(Request{Action: "verify", Code: code}) + clearPassword(&code) + if err != nil { + return err + } + value, err := readPassword() + if err != nil { + return err + } + defer clearPassword(&value) + if err := json.NewEncoder(conn).Encode(Request{Version: ProtocolVersion, Nonce: challenge.Nonce, Action: "reset", NewPassword: value}); err != nil { return fmt.Errorf("send reset request: %w", err) } response, err := DecodeResponse(conn) @@ -33,7 +103,7 @@ func SendReset(ctx context.Context, conn net.Conn, password string) error { return fmt.Errorf("read reset response: %w", err) } if !response.OK { - return fmt.Errorf("reset rejected: %s", response.Code) + return responseError(response.Code) } return nil } diff --git a/backend/services/adminreset/flow_test.go b/backend/services/adminreset/flow_test.go new file mode 100644 index 0000000..e618c74 --- /dev/null +++ b/backend/services/adminreset/flow_test.go @@ -0,0 +1,49 @@ +package adminreset + +import ( + "context" + "net" +) + +// 协议测试使用固定凭据夹具;生产验证在独立数据库集成测试中覆盖。 +type testResetFlow struct { + reset func(string) error + selected, verified bool +} + +func (f *testResetFlow) Methods() []Method { return []Method{{ID: "fixture", Label: "测试验证"}} } +func (f *testResetFlow) Start(method string) error { + if method != "fixture" { + return ErrInvalidRequest + } + f.selected = true + return nil +} +func (f *testResetFlow) Verify(code string) error { + if !f.selected || code != "fixture-code" { + return ErrInvalidRequest + } + f.verified = true + return nil +} +func (f *testResetFlow) Reset(value string) error { + if !f.verified { + return ErrInvalidRequest + } + f.verified = false + return f.reset(value) +} +func (f *testResetFlow) Close() {} +func testResetHandler(reset func(string) error) ResetHandler { + return func() (ResetSession, error) { return &testResetFlow{reset: reset}, nil } +} +func SendReset(ctx context.Context, conn net.Conn, value string) error { + step := 0 + return SendVerifiedReset(ctx, conn, func(string) (string, error) { + step++ + if step == 1 { + return "1", nil + } + return "fixture-code", nil + }, func() (string, error) { return value, nil }) +} diff --git a/backend/services/adminreset/password_linux.go b/backend/services/adminreset/password_linux.go index f2081bf..3e71715 100644 --- a/backend/services/adminreset/password_linux.go +++ b/backend/services/adminreset/password_linux.go @@ -30,6 +30,17 @@ func ReadProtectedPassword() (string, error) { } func askPassword(prompt string) (string, error) { + value, err := ReadProtectedInput(prompt) + if err != nil { + return "", err + } + if password.ValidateUserPassword(value) != nil { + return "", errors.New("密码长度无效") + } + return value, nil +} + +func ReadProtectedInput(prompt string) (string, error) { command := exec.Command("systemd-ask-password", "--no-tty", prompt) command.Stderr = io.Discard output, err := command.Output() @@ -39,9 +50,6 @@ func askPassword(prompt string) (string, error) { output = stripPasswordLineEnding(output) value := string(output) defer clearBytes(output) - if password.ValidateUserPassword(value) != nil { - return "", errors.New("密码长度无效") - } return value, nil } diff --git a/backend/services/adminreset/password_unsupported.go b/backend/services/adminreset/password_unsupported.go index e2a7a8e..5d86ef4 100644 --- a/backend/services/adminreset/password_unsupported.go +++ b/backend/services/adminreset/password_unsupported.go @@ -2,4 +2,5 @@ package adminreset -func ReadProtectedPassword() (string, error) { return "", ErrUnsupported } +func ReadProtectedPassword() (string, error) { return "", ErrUnsupported } +func ReadProtectedInput(string) (string, error) { return "", ErrUnsupported } diff --git a/backend/services/adminreset/password_windows.go b/backend/services/adminreset/password_windows.go index a92202c..9b07eb1 100644 --- a/backend/services/adminreset/password_windows.go +++ b/backend/services/adminreset/password_windows.go @@ -35,6 +35,20 @@ func ReadProtectedPassword() (string, error) { } func readWindowsPassword(prompt string) (string, error) { + value, err := ReadProtectedInput(prompt) + if err != nil { + return "", err + } + if password.ValidateUserPassword(value) != nil { + return "", errors.New("密码长度必须为 8 到 12 个字符") + } + return value, nil +} + +func ReadProtectedInput(prompt string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", errors.New("安全验证需要交互式控制台") + } fmt.Fprint(os.Stderr, prompt+": ") value, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) @@ -45,8 +59,5 @@ func readWindowsPassword(prompt string) (string, error) { for i := range value { value[i] = 0 } - if password.ValidateUserPassword(normalized) != nil { - return "", errors.New("密码长度必须为 8 到 12 个字符") - } return normalized, nil } diff --git a/backend/services/adminreset/protocol.go b/backend/services/adminreset/protocol.go index 7ac991b..2359fbf 100644 --- a/backend/services/adminreset/protocol.go +++ b/backend/services/adminreset/protocol.go @@ -17,9 +17,9 @@ import ( ) const ( - ProtocolVersion = 1 + ProtocolVersion = 2 maxControlMessageSize = 16 << 10 - controlRequestTimeout = 15 * time.Second + controlRequestTimeout = 5 * time.Minute defaultSessionCapacity = 128 ) @@ -36,16 +36,33 @@ type Request struct { Version int `json:"version"` Nonce string `json:"nonce"` NewPassword string `json:"new_password"` + Action string `json:"action"` + Method string `json:"method,omitempty"` + Code string `json:"code,omitempty"` } type Response struct { - OK bool `json:"ok"` - Code string `json:"code,omitempty"` - Message string `json:"message,omitempty"` - Nonce string `json:"nonce,omitempty"` + OK bool `json:"ok"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Nonce string `json:"nonce,omitempty"` + Methods []Method `json:"methods,omitempty"` } -type ResetHandler func(newPassword string) error +type Method struct { + ID string `json:"id"` + Label string `json:"label"` +} + +type ResetSession interface { + Methods() []Method + Start(string) error + Verify(string) error + Reset(string) error + Close() +} + +type ResetHandler func() (ResetSession, error) type SessionGuard struct { mu sync.Mutex @@ -154,7 +171,23 @@ func DecodeRequest(r io.Reader) (Request, error) { if err := decodeControlJSON(data, &request); err != nil { return Request{}, ErrInvalidRequest } - if request.Version != ProtocolVersion || strings.TrimSpace(request.Nonce) == "" || password.ValidateUserPassword(request.NewPassword) != nil { + if request.Version != ProtocolVersion || strings.TrimSpace(request.Nonce) == "" { + return Request{}, ErrInvalidRequest + } + switch request.Action { + case "select": + if request.Method == "" || request.Code != "" || request.NewPassword != "" { + return Request{}, ErrInvalidRequest + } + case "verify": + if request.Code == "" || len(request.Code) > 128 || request.Method != "" || request.NewPassword != "" { + return Request{}, ErrInvalidRequest + } + case "reset": + if request.Code != "" || request.Method != "" || password.ValidateUserPassword(request.NewPassword) != nil { + return Request{}, ErrInvalidRequest + } + default: return Request{}, ErrInvalidRequest } return request, nil @@ -221,11 +254,37 @@ func handleResetConnection(conn io.ReadWriter, setDeadline func(time.Time) error return } defer guard.Discard(nonce) - if err := EncodeResponse(conn, Response{OK: true, Code: "CHALLENGE", Nonce: nonce}); err != nil { + flow, err := handler() + if err != nil || flow == nil { + _ = EncodeResponse(conn, Response{Code: "VERIFICATION_UNAVAILABLE", Message: "暂无可用安全验证,请先登录安全中心配置邮箱、手机动态码或安全码"}) return } + defer flow.Close() + if err := EncodeResponse(conn, Response{OK: true, Code: "CHALLENGE", Nonce: nonce, Methods: flow.Methods()}); err != nil { + return + } + for _, action := range []string{"select", "verify"} { + request, err := DecodeRequest(conn) + if err != nil || request.Nonce != nonce || request.Action != action { + _ = EncodeResponse(conn, Response{Code: "INVALID_REQUEST", Message: "验证步骤无效,请重新启动重置"}) + return + } + if action == "select" { + err = flow.Start(request.Method) + } else { + err = flow.Verify(request.Code) + } + clearPassword(&request.Code) + if err != nil { + _ = EncodeResponse(conn, Response{Code: "VERIFICATION_FAILED", Message: "安全验证失败、已过期或请求过于频繁,请稍后重试"}) + return + } + if err := EncodeResponse(conn, Response{OK: true, Code: action}); err != nil { + return + } + } request, err := DecodeRequest(conn) - if err != nil || request.Nonce != nonce { + if err != nil || request.Nonce != nonce || request.Action != "reset" { _ = EncodeResponse(conn, Response{Code: "INVALID_REQUEST", Message: "重置请求无效"}) return } @@ -235,7 +294,7 @@ func handleResetConnection(conn io.ReadWriter, setDeadline func(time.Time) error } password := request.NewPassword defer clearPassword(&password) - if err := handler(password); err != nil { + if err := flow.Reset(password); err != nil { _ = EncodeResponse(conn, Response{Code: "RESET_FAILED", Message: "超级管理员密码重置失败"}) return } diff --git a/backend/services/adminreset/protocol_test.go b/backend/services/adminreset/protocol_test.go index af9a2dc..ebb60a6 100644 --- a/backend/services/adminreset/protocol_test.go +++ b/backend/services/adminreset/protocol_test.go @@ -80,9 +80,9 @@ func TestProtocolRejectsUnknownFieldsInvalidVersionAndTrailingData(t *testing.T) func TestProtocolEnforcesUnifiedPasswordLength(t *testing.T) { valid := []string{ - `{"version":1,"nonce":"n","new_password":"12345678"}`, - `{"version":1,"nonce":"n","new_password":"123456789012"}`, - `{"version":1,"nonce":"n","new_password":"密码aa"}`, + `{"version":2,"action":"reset","nonce":"n","new_password":"12345678"}`, + `{"version":2,"action":"reset","nonce":"n","new_password":"123456789012"}`, + `{"version":2,"action":"reset","nonce":"n","new_password":"密码aa"}`, } for _, input := range valid { if _, err := DecodeRequest(strings.NewReader(input)); err != nil { @@ -91,9 +91,9 @@ func TestProtocolEnforcesUnifiedPasswordLength(t *testing.T) { } invalid := []string{ - `{"version":1,"nonce":"n","new_password":"1234567"}`, - `{"version":1,"nonce":"n","new_password":"1234567890123"}`, - `{"version":1,"nonce":"n","new_password":"密码密码a"}`, + `{"version":2,"action":"reset","nonce":"n","new_password":"1234567"}`, + `{"version":2,"action":"reset","nonce":"n","new_password":"1234567890123"}`, + `{"version":2,"action":"reset","nonce":"n","new_password":"密码密码a"}`, } for _, input := range invalid { if _, err := DecodeRequest(strings.NewReader(input)); !errors.Is(err, ErrInvalidRequest) { diff --git a/backend/services/adminreset/reset.go b/backend/services/adminreset/reset.go index 6fbc761..9ab9763 100644 --- a/backend/services/adminreset/reset.go +++ b/backend/services/adminreset/reset.go @@ -20,6 +20,10 @@ var ( // ResetSuperAdmin 在主程序内完成超级管理员密码重置事务。 // smail-admin 只负责通过本机 IPC 传递一次性请求,不直接访问数据库。 func ResetSuperAdmin(engine *xorm.Engine, newPassword string, invalidators ...func(userID int, account string)) error { + return resetSuperAdmin(engine, newPassword, nil, invalidators...) +} + +func resetSuperAdmin(engine *xorm.Engine, newPassword string, authorize func(*xorm.Session, models.User) error, invalidators ...func(userID int, account string)) error { if engine == nil || newPassword == "" { return ErrInvalidRequest } @@ -65,6 +69,11 @@ func ResetSuperAdmin(engine *xorm.Engine, newPassword string, invalidators ...fu return ErrSuperAdminAmbiguous } target := targets[0] + if authorize != nil { + if err := authorize(session, target); err != nil { + return err + } + } version := target.SessionVersion + 1 if version <= 0 { version = 1 @@ -81,7 +90,7 @@ func ResetSuperAdmin(engine *xorm.Engine, newPassword string, invalidators ...fu MustChangePassword: 0, PasswordChangedAt: &now, } - affected, err := session.ID(target.ID).Where("session_version = ?", target.SessionVersion).Cols("password", "session_version", "security_version", "must_change_password", "password_changed_at").Update(updated) + affected, err := session.ID(target.ID).Where("session_version = ? AND security_version = ?", target.SessionVersion, target.SecurityVersion).Cols("password", "session_version", "security_version", "must_change_password", "password_changed_at").Update(updated) if err != nil { return fmt.Errorf("update super administrator: %w", err) } diff --git a/backend/services/adminreset/reset_integration_linux_test.go b/backend/services/adminreset/reset_integration_linux_test.go index ab6b466..a64e950 100644 --- a/backend/services/adminreset/reset_integration_linux_test.go +++ b/backend/services/adminreset/reset_integration_linux_test.go @@ -123,14 +123,14 @@ func TestUnixResetCommitsPasswordAndRevokesAllProtocolCodes(t *testing.T) { done := make(chan error, 1) invalidated := make(chan bool, 1) go func() { - done <- server.Serve(ctx, func(value string) error { + done <- server.Serve(ctx, testResetHandler(func(value string) error { return ResetSuperAdmin(engine, value, func(id int, account string) { var committed models.User found, err := engine.ID(id).Get(&committed) invalidated <- err == nil && found && id == users[0].ID && account == users[0].Account && committed.SessionVersion == users[0].SessionVersion+1 }) - }, NewSessionGuard(time.Minute)) + }), NewSessionGuard(time.Minute)) }() t.Cleanup(func() { cancel() diff --git a/backend/services/adminreset/transport_test.go b/backend/services/adminreset/transport_test.go index ff3607b..28e20ca 100644 --- a/backend/services/adminreset/transport_test.go +++ b/backend/services/adminreset/transport_test.go @@ -24,7 +24,9 @@ func TestUnixResetRoundTrip(t *testing.T) { defer cancel() guard := NewSessionGuard(time.Minute) called := make(chan string, 1) - go func() { _ = server.Serve(ctx, func(password string) error { called <- password; return nil }, guard) }() + go func() { + _ = server.Serve(ctx, testResetHandler(func(password string) error { called <- password; return nil }), guard) + }() var conn net.Conn for i := 0; i < 20; i++ { conn, err = DialControl(context.Background(), path) @@ -75,7 +77,10 @@ func TestUnixEmptyAllowedUIDsRejectConnection(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { - _ = server.Serve(ctx, func(string) error { t.Error("unauthorized handler was called"); return nil }, NewSessionGuard(time.Minute)) + _ = server.Serve(ctx, func() (ResetSession, error) { + t.Error("unauthorized handler was called") + return nil, ErrInvalidRequest + }, NewSessionGuard(time.Minute)) }() conn, err := DialControl(context.Background(), path) @@ -139,7 +144,7 @@ func TestUnixServerStopsWhenContextIsCancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { - done <- server.Serve(ctx, func(string) error { return nil }, NewSessionGuard(time.Minute)) + done <- server.Serve(ctx, testResetHandler(func(string) error { return nil }), NewSessionGuard(time.Minute)) }() cancel() diff --git a/backend/services/adminreset/transport_unix.go b/backend/services/adminreset/transport_unix.go index 2766730..8c35ff2 100644 --- a/backend/services/adminreset/transport_unix.go +++ b/backend/services/adminreset/transport_unix.go @@ -116,37 +116,7 @@ func (s *UnixServer) handle(conn net.Conn, handler ResetHandler, guard *SessionG _ = EncodeResponse(conn, Response{Code: "UNAUTHORIZED", Message: "本机调用者身份不受信任"}) return } - _ = conn.SetDeadline(time.Now().Add(controlRequestTimeout)) - defer conn.SetDeadline(time.Time{}) - nonce, err := guard.Issue() - if err != nil { - _ = EncodeResponse(conn, Response{Code: "CHANNEL_UNAVAILABLE", Message: "重置通道不可用"}) - return - } - defer guard.Discard(nonce) - if err := EncodeResponse(conn, Response{OK: true, Code: "CHALLENGE", Nonce: nonce}); err != nil { - return - } - request, err := DecodeRequest(conn) - if err != nil { - _ = EncodeResponse(conn, Response{Code: "INVALID_REQUEST", Message: "重置请求无效"}) - return - } - if request.Nonce != nonce { - _ = EncodeResponse(conn, Response{Code: "INVALID_REQUEST", Message: "重置请求无效"}) - return - } - if err := guard.Consume(request.Nonce); err != nil { - _ = EncodeResponse(conn, Response{Code: "REPLAY_OR_EXPIRED", Message: "重置会话已失效或已使用"}) - return - } - password := request.NewPassword - defer clearPassword(&password) - if err := handler(password); err != nil { - _ = EncodeResponse(conn, Response{Code: "RESET_FAILED", Message: "超级管理员密码重置失败"}) - return - } - _ = EncodeResponse(conn, Response{OK: true, Message: "超级管理员密码已重置"}) + handleResetConnection(conn, conn.SetDeadline, handler, guard) } func (s *UnixServer) authorize(conn net.Conn) error { diff --git a/backend/services/adminreset/transport_windows_test.go b/backend/services/adminreset/transport_windows_test.go index 439debb..a713f81 100644 --- a/backend/services/adminreset/transport_windows_test.go +++ b/backend/services/adminreset/transport_windows_test.go @@ -21,7 +21,7 @@ func testPipePath(t *testing.T) string { return fmt.Sprintf(`\\.\pipe\smail-admin-control-test-%d-%d`, os.Getpid(), time.Now().UnixNano()) } -func startWindowsTestServer(t *testing.T, handler ResetHandler) (string, context.CancelFunc, <-chan error) { +func startWindowsTestServer(t *testing.T, handler func(string) error) (string, context.CancelFunc, <-chan error) { t.Helper() if err := authorizeCurrentWindowsProcess(); err != nil { t.Skip("Windows IPC tests require an elevated local administrator or SYSTEM token") @@ -33,7 +33,7 @@ func startWindowsTestServer(t *testing.T, handler ResetHandler) (string, context } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) - go func() { done <- server.Serve(ctx, handler, NewSessionGuard(time.Minute)) }() + go func() { done <- server.Serve(ctx, testResetHandler(handler), NewSessionGuard(time.Minute)) }() t.Cleanup(func() { cancel() _ = server.Close() @@ -165,7 +165,9 @@ func TestWindowsServerStopsWhenContextIsCancelled(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) - go func() { done <- server.Serve(ctx, func(string) error { return nil }, NewSessionGuard(time.Minute)) }() + go func() { + done <- server.Serve(ctx, testResetHandler(func(string) error { return nil }), NewSessionGuard(time.Minute)) + }() cancel() select { case err := <-done: diff --git a/backend/services/adminreset/verification.go b/backend/services/adminreset/verification.go new file mode 100644 index 0000000..e807c73 --- /dev/null +++ b/backend/services/adminreset/verification.go @@ -0,0 +1,278 @@ +package adminreset + +import ( + "fmt" + "strings" + "sync" + "time" + + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/services/externalemail" + "git.pcux.cn/xuans/Smail/services/totp" + "git.pcux.cn/xuans/Smail/services/verification" + "git.pcux.cn/xuans/Smail/utils/password" + "xorm.io/xorm" +) + +// VerificationService 的限流状态跨连接保留,不能通过重新启动客户端刷新尝试次数。 +type VerificationService struct { + engine *xorm.Engine + email *externalemail.Repository + codes *credentials.Service + totp *totp.Service + verify *verification.Service + invalidate func(int, string) + mu sync.Mutex + attempts int + window time.Time +} + +func NewVerificationService(engine *xorm.Engine, email *externalemail.Repository, codes *credentials.Service, authenticator *totp.Service, verify *verification.Service, invalidate func(int, string)) *VerificationService { + return &VerificationService{engine: engine, email: email, codes: codes, totp: authenticator, verify: verify, invalidate: invalidate} +} + +type verifiedReset struct { + service *VerificationService + user models.User + methods []Method + emails map[string]models.ExternalEmail + authenticator models.TOTPAuthenticator + selected string + challenge verification.Challenge + context string + email string + recovery models.RecoveryCode + recoveryCode string + verified bool + expires time.Time +} + +func (s *VerificationService) Begin() (ResetSession, error) { + if s == nil || s.engine == nil || s.email == nil || s.codes == nil || s.totp == nil || s.verify == nil { + return nil, ErrInvalidRequest + } + var users []models.User + if err := s.engine.Find(&users); err != nil { + return nil, err + } + var targets []models.User + for _, user := range users { + if user.EffectiveRole() == access.RoleSuperAdmin { + targets = append(targets, user) + } + } + if len(targets) != 1 || targets[0].Disabled != 0 { + return nil, ErrSuperAdminNotFound + } + flow := &verifiedReset{service: s, user: targets[0], emails: make(map[string]models.ExternalEmail), expires: time.Now().Add(controlRequestTimeout)} + items, err := s.email.List(flow.user.ID) + if err != nil { + return nil, err + } + for _, item := range items { + if item.Status != models.ExternalEmailStatusVerified { + continue + } + address, err := s.email.Decrypt(item) + if err != nil { + return nil, err + } + id := fmt.Sprintf("email:%d", item.ID) + flow.emails[id] = item + flow.methods = append(flow.methods, Method{ID: id, Label: "发送验证码到 " + address}) + } + has, err := s.engine.Where("user_id = ? AND enabled = ? AND revoked_at IS NULL", flow.user.ID, true).Get(&flow.authenticator) + if err != nil { + return nil, err + } + if has { + flow.methods = append(flow.methods, Method{ID: "totp", Label: "手机验证器动态码"}) + } + has, err = s.codes.HasValidRecoveryCode(flow.user.ID) + if err != nil { + return nil, err + } + if has { + flow.methods = append(flow.methods, Method{ID: "recovery", Label: "安全码"}) + } + if len(flow.methods) == 0 { + return nil, ErrInvalidRequest + } + flow.context, err = NewNonce() + return flow, err +} + +func (f *verifiedReset) Methods() []Method { return f.methods } + +func (f *verifiedReset) Start(method string) error { + if f.selected != "" || !time.Now().Before(f.expires) { + return ErrInvalidRequest + } + available := false + for _, item := range f.methods { + if item.ID == method { + available = true + } + } + if !available { + return ErrInvalidRequest + } + f.selected = method + if item, ok := f.emails[method]; ok { + if err := f.checkCurrent(nil); err != nil { + return err + } + address, err := f.service.email.Decrypt(item) + if err != nil { + return err + } + f.email = address + f.challenge, err = f.service.verify.Issue(verification.IssueRequest{ + Purpose: verification.PurposeAdminLocalReset, Account: f.user.Account, UserID: f.user.ID, + Email: address, ResourceID: item.ID, CredentialVersion: item.CredentialVersion, + BrowserTransaction: f.context, Environment: "local-ipc", TTL: controlRequestTimeout, + }, "local-ipc") + return err + } + return nil +} + +func (s *VerificationService) allowAttempt() bool { + s.mu.Lock() + defer s.mu.Unlock() + if time.Since(s.window) >= 15*time.Minute { + s.window = time.Now() + s.attempts = 0 + } + if s.attempts >= 5 { + return false + } + s.attempts++ + return true +} + +func (f *verifiedReset) Verify(code string) error { + if f.verified || f.selected == "" || !f.service.allowAttempt() { + return ErrInvalidRequest + } + if err := f.checkCurrent(nil); err != nil { + return err + } + var err error + switch f.selected { + case "totp": + err = f.service.totp.Verify(f.user.ID, strings.TrimSpace(code)) + case "recovery": + code = strings.ToUpper(strings.TrimSpace(code)) + if len(code) < 12 || len(code) > 64 { + return ErrInvalidRequest + } + var items []models.RecoveryCode + if err := f.service.engine.Where("user_id = ? AND used_at IS NULL", f.user.ID).Find(&items); err != nil { + return err + } + err = ErrInvalidRequest + for _, item := range items { + matched, checkErr := password.Check(item.CodeHash, code) + if checkErr != nil { + return checkErr + } + if matched { + f.recovery = item + f.recoveryCode = code + err = nil + break + } + } + default: + item, ok := f.emails[f.selected] + if !ok { + return ErrInvalidRequest + } + err = f.service.verify.VerifyBoundWithContext(f.challenge.ID, f.user.Account, f.email, strings.TrimSpace(code), verification.PurposeAdminLocalReset, item.ID, "local-ipc", f.context) + } + if err != nil { + f.Close() + return err + } + f.verified = true + return nil +} + +// checkCurrent 同时绑定账号版本与所选凭据,验证后更换邮箱或撤销凭据不能继续重置。 +func (f *verifiedReset) checkCurrent(tx *xorm.Session) error { + if !time.Now().Before(f.expires) { + return ErrExpiredSession + } + if tx == nil { + tx = f.service.engine.NewSession() + defer tx.Close() + } + var user models.User + found, err := tx.ID(f.user.ID).Get(&user) + if err != nil { + return err + } + if !found || user.Disabled != 0 || user.EffectiveRole() != access.RoleSuperAdmin || user.Account != f.user.Account || user.Password != f.user.Password || user.SessionVersion != f.user.SessionVersion || user.SecurityVersion != f.user.SecurityVersion { + return ErrInvalidRequest + } + if item, ok := f.emails[f.selected]; ok { + var current models.ExternalEmail + found, err := tx.ID(item.ID).Get(¤t) + if err != nil { + return err + } + if !found || current.UserID != user.ID || current.Status != models.ExternalEmailStatusVerified || current.CredentialVersion != item.CredentialVersion || current.EmailCiphertext != item.EmailCiphertext { + return ErrInvalidRequest + } + } else if f.selected == "totp" { + var current models.TOTPAuthenticator + found, err := tx.ID(f.authenticator.ID).Get(¤t) + if err != nil { + return err + } + if !found || current.UserID != user.ID || !current.Enabled || current.RevokedAt != nil || current.CredentialVersion != f.authenticator.CredentialVersion || current.SecretCiphertext != f.authenticator.SecretCiphertext { + return ErrInvalidRequest + } + } else if f.selected == "recovery" && f.verified { + var current models.RecoveryCode + found, err := tx.ID(f.recovery.ID).Get(¤t) + if err != nil { + return err + } + if !found || current.UserID != user.ID || current.UsedAt != nil || current.CodeHash != f.recovery.CodeHash { + return ErrInvalidRequest + } + } + return nil +} + +func (f *verifiedReset) Reset(value string) error { + if !f.verified { + return ErrInvalidRequest + } + defer f.Close() + return resetSuperAdmin(f.service.engine, value, func(tx *xorm.Session, target models.User) error { + if target.ID != f.user.ID { + return ErrInvalidRequest + } + if err := f.checkCurrent(tx); err != nil { + return err + } + if f.selected == "recovery" { + return f.service.codes.ConsumeRecoveryCodeWithSession(tx, target.ID, f.recoveryCode) + } + return nil + }, f.service.invalidate) +} + +func (f *verifiedReset) Close() { + f.verified = false + f.expires = time.Time{} + clearPassword(&f.recoveryCode) + if f.challenge.ID != "" { + f.service.verify.Store.InvalidateChallenge(f.challenge.ID) + } +} diff --git a/backend/services/adminreset/verification_test.go b/backend/services/adminreset/verification_test.go new file mode 100644 index 0000000..1677e2a --- /dev/null +++ b/backend/services/adminreset/verification_test.go @@ -0,0 +1,302 @@ +package adminreset + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base32" + "encoding/binary" + "encoding/json" + "fmt" + "net" + "path/filepath" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/services/externalemail" + "git.pcux.cn/xuans/Smail/services/totp" + "git.pcux.cn/xuans/Smail/services/verification" + "git.pcux.cn/xuans/Smail/utils/password" + "xorm.io/xorm" +) + +func verificationFixture(t *testing.T) (*VerificationService, models.User, *string) { + t.Helper() + engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "verified-reset.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + if err := engine.Sync2(&models.User{}, &models.ExternalEmail{}, &models.TOTPAuthenticator{}, &models.SecurityQuestion{}, &models.RecoveryCode{}, &models.ProtocolCredential{}, &models.WebAuthnCredential{}); err != nil { + t.Fatal(err) + } + user := models.User{Account: "root", Role: "super_admin", SessionVersion: 2, SecurityVersion: 3} + if err := auth.SetPassword(&user, "OldPass123"); err != nil { + t.Fatal(err) + } + if _, err := engine.Insert(&user); err != nil { + t.Fatal(err) + } + root := t.TempDir() + vault, err := externalemail.NewVault(root) + if err != nil { + t.Fatal(err) + } + email, err := externalemail.NewRepository(engine, vault) + if err != nil { + t.Fatal(err) + } + codes, err := credentials.NewService(engine) + if err != nil { + t.Fatal(err) + } + authenticator, err := totp.NewService(engine, root) + if err != nil { + t.Fatal(err) + } + store, err := verification.NewStore(20) + if err != nil { + t.Fatal(err) + } + var delivered string + verify, err := verification.NewService(store, func(_ verification.Challenge, code string) error { delivered = code; return nil }, nil) + if err != nil { + t.Fatal(err) + } + return NewVerificationService(engine, email, codes, authenticator, verify, nil), user, &delivered +} + +func addRecovery(t *testing.T, s *VerificationService, user models.User) string { + t.Helper() + code := "ABCD-EFGH-IJKL-MNOP" + hash, err := password.Hash(code) + if err != nil { + t.Fatal(err) + } + if _, err := s.engine.Insert(&models.RecoveryCode{UserID: user.ID, CodeHash: hash}); err != nil { + t.Fatal(err) + } + return code +} + +func TestVerifiedResetWithoutEmailOverProtocol(t *testing.T) { + s, user, delivered := verificationFixture(t) + if _, err := s.Begin(); err == nil { + t.Fatal("unconfigured account allowed") + } + code := addRecovery(t, s, user) + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer server.Close() + handleResetConnection(server, server.SetDeadline, s.Begin, NewSessionGuard(time.Minute)) + }() + defer client.Close() + steps := 0 + err := SendVerifiedReset(context.Background(), client, func(string) (string, error) { + steps++ + if steps == 1 { + return "1", nil + } + return code, nil + }, func() (string, error) { + if steps != 2 { + t.Error("password asked before verification") + } + return "NewPass123", nil + }) + if err != nil { + t.Fatal(err) + } + <-done + var updated models.User + if _, err := s.engine.ID(user.ID).Get(&updated); err != nil { + t.Fatal(err) + } + if !password.Verify(updated.Password, "NewPass123") || updated.SessionVersion != 3 || updated.SecurityVersion != 4 { + t.Fatal("reset did not commit") + } + if ready, err := s.codes.HasValidRecoveryCode(user.ID); err != nil || ready { + t.Fatal("recovery code not consumed") + } + if *delivered != "" { + t.Fatal("mail sent without selection") + } +} + +func TestVerifiedResetRejectsRevokedCredentialAndExpiredGrant(t *testing.T) { + for _, change := range []string{"code", "account", "expired"} { + t.Run(change, func(t *testing.T) { + s, user, _ := verificationFixture(t) + code := addRecovery(t, s, user) + flow, err := s.Begin() + if err != nil { + t.Fatal(err) + } + defer flow.Close() + if err := flow.Start("recovery"); err != nil { + t.Fatal(err) + } + if err := flow.Reset("NewPass123"); err == nil { + t.Fatal("unverified reset accepted") + } + if err := flow.Verify(code); err != nil { + t.Fatal(err) + } + switch change { + case "code": + if err := s.codes.RevokeRecoveryCodes(user.ID); err != nil { + t.Fatal(err) + } + case "account": + if _, err := s.engine.ID(user.ID).Cols("security_version").Update(&models.User{SecurityVersion: 8}); err != nil { + t.Fatal(err) + } + case "expired": + flow.(*verifiedReset).expires = time.Now().Add(-time.Second) + } + if err := flow.Reset("NewPass123"); err == nil { + t.Fatal("stale grant accepted") + } + var unchanged models.User + if _, err := s.engine.ID(user.ID).Get(&unchanged); err != nil { + t.Fatal(err) + } + if unchanged.Password != user.Password { + t.Fatal("rejected request changed password") + } + }) + } +} + +func TestVerifiedResetEmailIsExplicitAndBound(t *testing.T) { + s, user, delivered := verificationFixture(t) + item, err := s.email.Bind(externalemail.BindRequest{UserID: user.ID, Email: "root@example.com", Type: models.ExternalEmailTypeBackup}) + if err != nil { + t.Fatal(err) + } + if _, err := s.engine.ID(item.ID).Cols("status").Update(&models.ExternalEmail{Status: models.ExternalEmailStatusVerified}); err != nil { + t.Fatal(err) + } + flow, err := s.Begin() + if err != nil { + t.Fatal(err) + } + defer flow.Close() + if *delivered != "" { + t.Fatal("mail sent before selection") + } + if err := flow.Start(flow.Methods()[0].ID); err != nil { + t.Fatal(err) + } + if *delivered == "" { + t.Fatal("mail not sent") + } + if err := flow.Verify(*delivered); err != nil { + t.Fatal(err) + } + if _, err := s.engine.ID(item.ID).Cols("status").Update(&models.ExternalEmail{Status: models.ExternalEmailStatusDisabled}); err != nil { + t.Fatal(err) + } + if err := flow.Reset("NewPass123"); err == nil { + t.Fatal("disabled email accepted") + } +} + +func TestVerifiedResetTOTPAndReplay(t *testing.T) { + s, user, _ := verificationFixture(t) + enrollment, err := s.totp.BeginEnrollment(user.ID, user.Account) + if err != nil { + t.Fatal(err) + } + secret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(enrollment.Secret) + if err != nil { + t.Fatal(err) + } + makeCode := func(step int64) string { + counter := make([]byte, 8) + binary.BigEndian.PutUint64(counter, uint64(step)) + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write(counter) + digest := mac.Sum(nil) + offset := digest[len(digest)-1] & 15 + return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(digest[offset:offset+4])&0x7fffffff)%1000000) + } + step := time.Now().Unix() / 30 + if err := s.totp.ConfirmEnrollment(user.ID, enrollment.ID, makeCode(step-1)); err != nil { + t.Fatal(err) + } + flow, err := s.Begin() + if err != nil { + t.Fatal(err) + } + defer flow.Close() + if err := flow.Start("totp"); err != nil { + t.Fatal(err) + } + if err := flow.Verify(makeCode(step)); err != nil { + t.Fatal(err) + } + if err := s.totp.Verify(user.ID, makeCode(step)); err == nil { + t.Fatal("TOTP replay accepted") + } + if err := flow.Reset("NewPass123"); err != nil { + t.Fatal(err) + } +} + +func TestVerifiedResetLimitsAcrossConnections(t *testing.T) { + s, user, _ := verificationFixture(t) + code := addRecovery(t, s, user) + for i := 0; i < 5; i++ { + flow, err := s.Begin() + if err != nil { + t.Fatal(err) + } + if err := flow.Start("recovery"); err != nil { + t.Fatal(err) + } + if err := flow.Verify("WRONG-CODE-123456"); err == nil { + t.Fatal("wrong code accepted") + } + flow.Close() + } + flow, err := s.Begin() + if err != nil { + t.Fatal(err) + } + defer flow.Close() + if err := flow.Start("recovery"); err != nil { + t.Fatal(err) + } + if err := flow.Verify(code); err == nil { + t.Fatal("new connection bypassed limit") + } +} + +func TestProtocolRejectsSkippedVerification(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + done := make(chan struct{}) + go func() { + defer close(done) + defer server.Close() + handleResetConnection(server, server.SetDeadline, testResetHandler(func(string) error { t.Error("reset bypassed verification"); return nil }), NewSessionGuard(time.Minute)) + }() + challenge, err := DecodeResponse(client) + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(client).Encode(Request{Version: ProtocolVersion, Nonce: challenge.Nonce, Action: "reset", NewPassword: "NewPass123"}); err != nil { + t.Fatal(err) + } + result, err := DecodeResponse(client) + if err != nil || result.OK { + t.Fatal("skipped verification accepted") + } + <-done +} diff --git a/backend/services/verification/delivery.go b/backend/services/verification/delivery.go index e8a8ebe..8d01f35 100644 --- a/backend/services/verification/delivery.go +++ b/backend/services/verification/delivery.go @@ -228,6 +228,8 @@ func CodeMessage(purpose Purpose, code string) (string, string) { purposeText = "完成密码恢复验证" case PurposePasswordChange: purposeText = "完成修改密码安全验证" + case PurposeAdminLocalReset: + purposeText = "完成超级管理员本机密码重置验证" case PurposeExternalEmailBind: purposeText = "完成外部邮箱绑定验证" case PurposeExternalEmailChange: diff --git a/backend/services/verification/store.go b/backend/services/verification/store.go index 3f56eb0..301bd84 100644 --- a/backend/services/verification/store.go +++ b/backend/services/verification/store.go @@ -35,6 +35,7 @@ const ( PurposeLogin Purpose = "login" PurposePasswordRecover Purpose = "password_recover" PurposePasswordChange Purpose = "password_change" + PurposeAdminLocalReset Purpose = "admin_local_reset" PurposeExternalEmailBind Purpose = "external_email_bind" PurposeExternalEmailChange Purpose = "external_email_change" ) @@ -191,7 +192,7 @@ func (s *Store) issue(request IssueRequest, delivery Delivery, onVerified func() // IsSupportedPurpose 保证验证码只能用于已确认的业务流程。 func IsSupportedPurpose(purpose Purpose) bool { switch purpose { - case PurposeLogin, PurposePasswordRecover, PurposePasswordChange, PurposeExternalEmailBind, PurposeExternalEmailChange: + case PurposeLogin, PurposePasswordRecover, PurposePasswordChange, PurposeExternalEmailBind, PurposeExternalEmailChange, PurposeAdminLocalReset: return true default: return false diff --git a/deploy/systemd/smail-admin.service b/deploy/systemd/smail-admin.service index 8a1aac0..e25fc87 100644 --- a/deploy/systemd/smail-admin.service +++ b/deploy/systemd/smail-admin.service @@ -30,5 +30,5 @@ RestrictRealtime=true RestrictSUIDSGID=true LockPersonality=true MemoryDenyWriteExecute=true -TimeoutStartSec=180s +TimeoutStartSec=330s TimeoutStopSec=5s diff --git a/docs/安全威胁模型与敏感数据流设计.md b/docs/安全威胁模型与敏感数据流设计.md index 106a11b..0a5038c 100644 --- a/docs/安全威胁模型与敏感数据流设计.md +++ b/docs/安全威胁模型与敏感数据流设计.md @@ -22,7 +22,7 @@ ### 1.2 当前安全结论 -- 超级管理员自身的密码重置只有部署端本机 IPC 链路,HTTP 路由不提供该能力;`smail-admin` 不直接访问数据库。 +- 超级管理员不能通过登录页恢复密码;本人安全中心保留已认证操作,部署端重置使用本机 IPC;`smail-admin` 不直接访问数据库。 - Linux 使用权限受限的 Unix Domain Socket 和对端 UID 校验;Windows 使用安全描述符限制的 Named Pipe,并校验客户端进程令牌是否为 SYSTEM 或提升后的管理员。 - IPC 使用一次性 nonce、有效期、单帧大小上限、协议版本和未知字段拒绝;nonce 在消费时原子删除,不能跨连接复用。 - 邮箱验证码只保存在签发认证进程内存,验证码只以 HMAC-SHA-256 校验值保存;进程重启后内存任务和临时密钥失效。 @@ -98,7 +98,7 @@ flowchart LR | 外部安全邮箱 | 绑定/登录/恢复服务 | 解密后用于匹配、投递和脱敏返回 | AES-GCM 加密密文 + 独立 HMAC 指纹 | 未验证绑定失败回滚;停用后不可用于认证;解密明文只短暂存在 | | WebAuthn 凭据 | 浏览器 ceremony | WebAuthn 请求和服务端验证 | 独立凭据密钥保护的序列化凭据 | ceremony 会话限时一次;凭据可撤销;恢复或重置按权限撤销 | | 登录二次验证状态 | `loginVerificationState` 和验证码 Store | 登录密码成功后到验证码验证完成 | 仅内存 | 十分钟过期、成功消费、密码/会话版本变化时删除 | -| IPC nonce | `SessionGuard` | 管理进程和主服务之间一轮连接 | 仅内存 | 两分钟默认有效;消费、断开或过期删除 | +| IPC nonce | `SessionGuard` | 管理进程和主服务之间一轮连接 | 仅内存 | 生产重置连接五分钟有效;消费、断开或过期删除 | | SMTP 认证密码 | 配置装载到 SMTP 投递 | SMTP 建连和认证 | 受配置 Secret 管理约束,不进入验证码事件 | 由配置 Secret 生命周期管理;不得进入邮件正文、日志或响应 | | 临时进程密钥 | 验证码 Store、邮箱 Vault、审计指纹服务 | 进程内加密、HMAC 和校验 | 验证码临时密钥禁止持久化;外部邮箱和审计密钥按密钥管理策略保存 | 进程退出后验证码密钥失效;其他密钥不得通过诊断导出 | @@ -106,14 +106,14 @@ flowchart LR ### 4.1 部署端超级管理员重置 -1. 部署人员在受控主机上按正式部署命令启动 `smail-admin`,新密码从无回显安全交互读取,不通过命令行参数、环境变量、服务名或普通文件传递。 +1. 部署人员在受控主机上按正式部署命令启动 `smail-admin`,安全凭据与新密码从无回显交互读取,不通过命令行参数、环境变量、服务名或普通文件传递。 2. `smail-admin` 连接固定本机 IPC 地址:Linux 为 Unix Domain Socket,Windows 为 Named Pipe。 -3. 主服务先校验对端身份,再签发一次性 nonce;客户端只能在当前连接返回同一 nonce 和新密码。 +3. 主服务先校验对端身份,再签发一次性 nonce 和可用安全方式;客户端依次选择方式、验证邮箱验证码或 TOTP 或安全码,最后才提交新密码。选择邮箱前不发送验证码。 4. 主服务校验协议版本、单帧大小、字段集合、密码长度、nonce 一致性和 nonce 未过期/未消费。 -5. 主服务在同一数据库事务中定位唯一超级管理员、更新 Argon2id 密码哈希、递增 `session_version`、撤销其恢复凭据,然后提交事务。 +5. 主服务在同一数据库事务中定位唯一超级管理员、复核账号及所选凭据版本、核销所选安全码、更新 Argon2id 密码哈希、递增会话和安全版本、撤销其恢复凭据,然后提交事务。 6. 提交成功后仅在内存中撤销该用户的会话、验证码、恢复票据和 WebAuthn 状态;客户端获得不含数据库细节的结果。 -明确禁止:HTTP 路由、浏览器页面、远程 TCP、离线恢复码或外部邮箱验证码触发超级管理员自身重置。 +明确禁止:登录页恢复超级管理员、通过 HTTP 或远程 TCP 暴露本机重置通道、未验证账号安全凭据就提交本机重置。邮箱、TOTP、安全码可用于受保护本机通道的身份验证。当前操作规则见《超级管理员安全验证与本机重置》。 ### 4.2 外部安全邮箱绑定 @@ -199,7 +199,7 @@ flowchart LR | T-13 | 未验证、已停用或他人外部邮箱被用于恢复/登录 | 查询只接受 verified 状态;资源 ID 和 UserID 双重绑定;停用时失效验证码、恢复票据和登录状态 | `externalemail.TestRepositoryBindsVerifiesAndScopesByUser`;`externalemail.TestRepositoryAbandonsOnlyOwnedPendingBinding`;`backend/controllers/security.go` 中 `DisableExternalEmail` 的状态失效代码审查 | | T-14 | 数据库读取者通过运营数据还原账号、邮箱或 IP | 运营字段使用独立 HMAC 指纹;限流维度键哈希;不保存完整值;按期限清理 | `verification.TestEventStorePersistsOnlyOperationalFingerprintsAndCleansAfterRetention`;`verification.TestPersistentRateLimiterDoesNotPersistSensitiveValues` | | T-15 | 主服务重启后旧验证码或恢复流程被恢复使用 | 验证码 Store 的临时密钥和任务只在内存;恢复票据、诱饵挑战和 WebAuthn ceremony 不从数据库恢复 | `verification.TestStoreRestartInvalidatesAllChallenges`;`recovery.TestRecoveryStoresOnlyTicketDigestAndLimitsCapacity`;`webauthn.TestCeremonySessionCapacityAndExpiredPruning` | -| T-16 | 超级管理员通过 Web、外部邮箱或恢复码重置自身密码 | 恢复用户加载明确拒绝 `super_admin`;部署端重置单独走 IPC;没有 Web 路由调用 `ResetSuperAdmin` | `recovery.TestRecoveryDeniesSuperAdministrator`;`controllers.TestRecoveryRejectsWhenDisabled`;路由审计:`backend/listen/http_server/http_server.go` 未注册网络超级管理员重置入口 | +| T-16 | 超级管理员通过登录页恢复或绕过本机身份验证 | 登录页恢复拒绝 `super_admin`;部署端单独走受保护 IPC 并强制验证账号安全凭据 | `recovery.TestRecoveryDeniesSuperAdministrator`;`controllers.TestRecoveryRejectsWhenDisabled`;`services/adminreset/verification_test.go`;本机用途不对 HTTP 验证接口开放 | | T-17 | 恶意请求超过协议帧、JSON 或 SMTP 头字段限制 | IPC 最大帧 16 KiB、JSON unknown field 拒绝;邮箱和 SMTP Host/From 禁止换行和非法格式;HTTP JSON 单值解析 | `adminreset.TestProtocolRejectsOversizedFrame`;`controllers.TestDecodeJSONRequestRejectsUnsafeBodies`;`verification.TestSMTPHostValidation` | | T-18 | 外部错误把数据库细节或秘密返回给调用方 | IPC 统一返回 `RESET_FAILED`;HTTP 使用稳定业务错误;日志只记录内部错误上下文,不拼接密码、验证码和完整邮箱 | `adminreset.TestWindowsNamedPipeReturnsSafeHandlerFailure`;`controllers.TestSecurityHandlersRejectUnauthenticatedRequests`;安全响应形状测试 | diff --git a/docs/超级管理员安全验证与本机重置.md b/docs/超级管理员安全验证与本机重置.md new file mode 100644 index 0000000..c577b41 --- /dev/null +++ b/docs/超级管理员安全验证与本机重置.md @@ -0,0 +1,41 @@ +# 超级管理员安全验证与本机重置 + +## 登录后的强制设置 + +超级管理员必须至少配置以下一种方式: + +- 外部邮箱:完成绑定验证码验证,待验证邮箱不计入。 +- 手机动态码:完成 TOTP 绑定并启用,已撤销或未确认绑定不计入。 +- 安全码:生成并保存,至少保留一个未使用的有效安全码。 + +三种方式均未配置时,登录后只能进入安全设置或注销,不能跳过进入业务页面,后端业务接口也会拒绝请求。完成任意一种后解除限制;撤销最后一种时恢复限制。已有有效方式的账号无需重复设置。 + +管理员和普通用户仍按原规则提示设置安全码和邮箱,本次不强制要求所有账号启用 TOTP。 + +## 部署主机重置流程 + +在部署主机以 root 执行: + +```sh +systemctl start smail-admin +``` + +1. 输入可用验证方式的序号。仅显示已配置且有效的方式。 +2. 选择邮箱后才发送验证码;选择动态码或安全码不发邮件。 +3. 输入邮箱验证码、手机验证器动态码或一个安全码。 +4. 验证通过后,输入两次新密码。 +5. 事务成功后旧会话失效,旧安全码和协议凭据撤销。已绑定邮箱和 TOTP 保留。 + +未绑定邮箱时可以使用 TOTP 或安全码。若唯一方式是安全码,重置后重新登录会要求再次配置安全方式。新安全码只展示一次,请妥善保存。 + +每轮连接最多五分钟,本机验证每十五分钟最多尝试五次,重新启动客户端不会刷新次数。失败后重新启动命令;不能跳过验证直接提交密码。若命令失败,可用 `journalctl -u smail-admin -n 20 --no-pager` 查看中文原因。 + +尚无任何安全凭据时,请先用现有密码登录安全中心配置。已经无法登录且没有任何有效凭据时,本通道不会免验证重置。 + +## 通道边界与升级 + +登录页继续禁止重置超级管理员。本人已登录的安全中心操作遵守现有认证要求;部署端重置只使用受保护的本机 IPC,不新增 HTTP 或 TCP 入口。 + +Linux 使用 Unix Domain Socket 及对端身份校验,Windows 使用受限 Named Pipe。邮箱验证码使用独立的本机重置用途,不能经公开验证码接口申请或验证。验证会绑定当前账号与凭据版本,更换或撤销所选凭据后不能继续重置。 + +本次协议升级到第二版,部署时必须同时替换主程序和 `smail-admin`,并同步更新 systemd 单元的超时时间。第一版客户端不能跳过新验证流程。 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1db37f6..1872518 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -29,11 +29,11 @@ watch(
-
+
- + diff --git a/frontend/src/views/components/HomeHeader/HomeHeader.js b/frontend/src/views/components/HomeHeader/HomeHeader.js index 1930807..45f0772 100644 --- a/frontend/src/views/components/HomeHeader/HomeHeader.js +++ b/frontend/src/views/components/HomeHeader/HomeHeader.js @@ -58,6 +58,7 @@ export function usePage() { { key: 'sender-filter', label: '黑白名单', hint: '发件人信任与拦截', icon: Checked, component: SenderFilterManagement, capability: 'user.read' }, { key: 'logs', label: '日志', hint: '操作、收发信与运行记录', icon: Document, component: LogSettings, capability: 'audit.read' }, ].filter(item => { + if (globalStatus.userInfos?.securitySetupRequired && item.key !== 'security') return false // 日志数据范围由服务端强制过滤;菜单只决定入口是否可见。 return globalStatus.hasCapability(item.capability) || (item.key === 'plugins' && globalStatus.hasCapability('plugin.read_global')) diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index a7ee3ab..89d5f74 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -4,6 +4,7 @@ import { ElMessageBox, ElNotification } from 'element-plus' import lang from '../../../i18n/i18n' import { http } from '@/utils/axios' +import { requestWithRecentAuthentication } from '@/utils/recentAuthentication' import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' import { normalizeCreationOptions, serializeCredential, webAuthnSupported } from '@/utils/webauthn' @@ -21,13 +22,13 @@ export function usePage() { } options.push({ value: 'password', label: lang.modify_pwd }) options.push({ value: 'question', label: '密保问题' }) - if (!isSuperAdmin.value) { - options.push({ value: 'recovery', label: '安全码' }) - } + options.push({ value: 'recovery', label: '安全码' }) options.push({ value: 'external-email', label: '外部安全邮箱' }) options.push({ value: 'totp', label: '手机动态码' }) options.push({ value: 'webauthn', label: '安全密钥' }) - return options + return globalStatus.userInfos.securitySetupRequired + ? options.filter(item => ['recovery', 'external-email', 'totp'].includes(item.value)) + : options }) const selectedSection = computed({ get: () => globalStatus.securityActiveSection, @@ -87,6 +88,7 @@ export function usePage() { } const loadCredentials = async () => { + if (globalStatus.userInfos.securitySetupRequired) return if (!supported || !webAuthnEnabled.value) return credentialsLoading.value = true try { @@ -107,9 +109,7 @@ export function usePage() { http.get('/api/v1/security/credentials/question'), http.get('/api/v1/security/external-email/list'), ] - if (!isSuperAdmin.value) { - requests.push(http.get('/api/v1/security/credentials/recovery-codes')) - } + requests.push(http.get('/api/v1/security/credentials/recovery-codes')) const [question, external, recovery] = await Promise.all(requests) if (question.errorNo === 0 && question.data) { securityQuestion.configured = Boolean(question.data.configured) @@ -131,6 +131,7 @@ export function usePage() { const response = await http.get('/api/v1/security/external-email/list') if (response.errorNo !== 0) throw new Error(response.errorMsg || '读取外部邮箱失败') externalEmails.value = Array.isArray(response.data) ? response.data : [] + await globalStatus.refresh() return true } catch (error) { notify(error?.message || '读取外部邮箱失败') @@ -214,10 +215,11 @@ export function usePage() { const generateRecoveryCodes = async () => { try { await ElMessageBox.confirm('生成新恢复码会撤销当前仍有效的恢复码,请确认已准备好安全保存。', '生成离线恢复码', { type: 'warning' }) - const res = await http.post('/api/v1/security/credentials/recovery-codes/generate', { count: 10 }) + const res = await requestWithRecentAuthentication(() => http.post('/api/v1/security/credentials/recovery-codes/generate', { count: 10 })) if (res.errorNo !== 0 || !Array.isArray(res.data?.codes)) throw new Error(res.errorMsg || '离线恢复码生成失败') generatedCodes.value = res.data.codes recoveryConfigured.value = true + await globalStatus.refresh() notify('恢复码已生成,请仅保存这一次显示的内容', 'success') } catch (error) { if (error !== 'cancel' && error !== 'close') notify(error?.message || '离线恢复码生成失败') @@ -227,10 +229,11 @@ export function usePage() { const revokeRecoveryCodes = async () => { try { await ElMessageBox.confirm('撤销后现有离线恢复码将立即失效,是否继续?', '撤销离线恢复码', { type: 'warning' }) - const res = await http.post('/api/v1/security/credentials/recovery-codes/revoke', {}) + const res = await requestWithRecentAuthentication(() => http.post('/api/v1/security/credentials/recovery-codes/revoke', {})) if (res.errorNo !== 0) throw new Error(res.errorMsg || '撤销恢复码失败') recoveryConfigured.value = false generatedCodes.value = [] + await globalStatus.refresh() notify('离线恢复码已撤销', 'success') } catch (error) { if (error !== 'cancel' && error !== 'close') notify(error?.message || '撤销恢复码失败') @@ -252,6 +255,7 @@ export function usePage() { const res = await http.post('/api/v1/security/external-email/disable', { id: item.id }) if (res.errorNo !== 0) throw new Error(res.errorMsg || '外部邮箱停用失败') await loadSecurityData() + await globalStatus.refresh() notify('外部邮箱已停用', 'success') } catch (error) { if (error !== 'cancel' && error !== 'close') notify(error?.message || '外部邮箱停用失败') diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index bfaed04..6bafc05 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -82,7 +82,7 @@ -
+

安全码

安全码只在生成时显示一次,请妥善保存。

生成新恢复码撤销
diff --git a/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.js b/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.js index 81a8380..a00d1fa 100644 --- a/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.js +++ b/frontend/src/views/components/SecuritySettings/TOTPSettings/TOTPSettings.js @@ -2,6 +2,7 @@ import { onBeforeUnmount, onMounted, ref } from 'vue' import QRCode from 'qrcode' import { ElMessage } from 'element-plus' import { http } from '@/utils/axios' +import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication' export function useTOTPSettings() { @@ -49,6 +50,7 @@ export function useTOTPSettings() { } else { clearEnrollment() await load() + if (action !== 'cancel') await useGlobalStatusStore().refresh() if (action !== 'cancel') ElMessage.success(action === 'confirm' ? '手机动态码已启用' : '手机动态码已关闭') } } catch (error) { diff --git a/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.js b/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.js index 030caa1..db9777b 100644 --- a/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.js +++ b/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.js @@ -6,23 +6,35 @@ export function usePage() { const globalStatus = useGlobalStatusStore() const saving = ref(false) const error = ref('') + const required = computed(() => globalStatus.userInfos.securitySetupRequired === true) const visible = computed(() => globalStatus.isLogin && globalStatus.userInfos.showSecuritySetupPrompt === true + && !(required.value && globalStatus.settingsDrawerVisible) && !globalStatus.userInfos.must_change_password) const isSuperAdmin = computed(() => globalStatus.role === 'super_admin') const options = computed(() => { const items = [] - if (!isSuperAdmin.value && !globalStatus.userInfos.recoveryCodesConfigured) { + if (!globalStatus.userInfos.recoveryCodesConfigured) { items.push({ key: 'recovery', title: '安全码', description: '生成安全码并妥善保存。' }) } if (!globalStatus.userInfos.externalEmailConfigured) { items.push({ key: 'external-email', title: '外部邮箱', description: '绑定邮箱并完成验证。' }) } + if (isSuperAdmin.value && !globalStatus.userInfos.totpConfigured) { + items.push({ key: 'totp', title: '手机动态码', description: '使用手机验证器完成绑定。' }) + } return items }) const acknowledge = async section => { if (saving.value) return + if (required.value) { + if (!section) return + globalStatus.securityActiveSection = section + globalStatus.settingsActiveTab = 'security' + globalStatus.settingsDrawerVisible = true + return + } saving.value = true error.value = '' const account = globalStatus.userInfos.account @@ -44,5 +56,19 @@ export function usePage() { } } - return { visible, isSuperAdmin, options, saving, error, acknowledge } + const logout = async () => { + if (saving.value) return + saving.value = true + error.value = '' + try { + const result = await http.post('/api/v1/logout', {}) + if (result.errorNo !== 0) throw new Error(result.errorMsg || '注销失败,请重试') + location.reload() + } catch (failure) { + error.value = failure?.message || '注销失败,请重试' + saving.value = false + } + } + + return { visible, isSuperAdmin, required, options, saving, error, acknowledge, logout } } diff --git a/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.vue b/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.vue index 4ad4df5..25e8508 100644 --- a/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.vue +++ b/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.vue @@ -8,7 +8,7 @@ :show-close="false" class="security-setup-dialog" > -

{{ isSuperAdmin ? '请绑定外部邮箱。未完成前,每次登录都会提醒。' : '请设置安全码并绑定外部邮箱。未完成前,每次登录都会提醒。' }}

+

{{ isSuperAdmin ? '请至少启用一种安全验证后继续。' : '请设置安全码并绑定外部邮箱。未完成前,每次登录都会提醒。' }}

@@ -20,7 +20,8 @@
@@ -28,8 +29,9 @@ diff --git a/test/frontend/security-setup.test.mjs b/test/frontend/security-setup.test.mjs index 926f655..243c226 100644 --- a/test/frontend/security-setup.test.mjs +++ b/test/frontend/security-setup.test.mjs @@ -21,16 +21,33 @@ function fixture(post = async () => ({ errorNo: 0 })) { return { store, page: createPage(computed, ref, { post }, () => store) } } -test('超级管理员只提醒邮箱,强制改密时延后', () => { +test('超级管理员三种方式任选其一,强制改密时延后', () => { const { store, page } = fixture() assert.equal(page.visible.value, true) - assert.deepEqual(Array.from(page.options.value, item => item.key), ['external-email']) + assert.deepEqual(Array.from(page.options.value, item => item.key), ['recovery', 'external-email', 'totp']) store.userInfos.must_change_password = 1 assert.equal(page.visible.value, false) store.userInfos.must_change_password = 0 assert.equal(page.visible.value, true) }) +test('强制设置不可跳过,关闭设置窗口后继续要求完成', async () => { + let calls = 0 + const { store, page } = fixture(async () => { calls++; return { errorNo: 0 } }) + store.userInfos.securitySetupRequired = true + await page.acknowledge() + assert.equal(page.visible.value, true) + await page.acknowledge('totp') + assert.equal(calls, 0) + assert.equal(page.visible.value, false) + assert.equal(store.securityActiveSection, 'totp') + store.settingsDrawerVisible = false + assert.equal(page.visible.value, true) + store.userInfos.securitySetupRequired = false + store.userInfos.showSecuritySetupPrompt = false + assert.equal(page.visible.value, false) +}) + test('确认仅关闭本次提醒,下次登录未完成设置仍提醒', async () => { const requests = [] const { store, page } = fixture(async path => { requests.push(path); return { errorNo: 0 } })