强制超级管理员配置安全验证并保护本机密码重置
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-19 00:24:27 +08:00
parent de8c298bd5
commit 67931eab5d
38 changed files with 1128 additions and 135 deletions

View File

@ -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 "请求无效或当前平台不支持"
}

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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)
}

View File

@ -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)

View File

@ -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
}

View File

@ -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))

View File

@ -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

View File

@ -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)

View File

@ -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("管理员恢复通道运行失败")

View File

@ -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
}

View File

@ -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 })
}

View File

@ -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
}

View File

@ -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 }

View File

@ -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
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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)
}

View File

@ -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()

View File

@ -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()

View File

@ -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 {

View File

@ -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:

View File

@ -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(&current)
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(&current)
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(&current)
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)
}
}

View File

@ -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
}

View File

@ -228,6 +228,8 @@ func CodeMessage(purpose Purpose, code string) (string, string) {
purposeText = "完成密码恢复验证"
case PurposePasswordChange:
purposeText = "完成修改密码安全验证"
case PurposeAdminLocalReset:
purposeText = "完成超级管理员本机密码重置验证"
case PurposeExternalEmailBind:
purposeText = "完成外部邮箱绑定验证"
case PurposeExternalEmailChange:

View File

@ -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

View File

@ -30,5 +30,5 @@ RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
TimeoutStartSec=180s
TimeoutStartSec=330s
TimeoutStopSec=5s

View File

@ -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`;安全响应形状测试 |

View File

@ -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 单元的超时时间。第一版客户端不能跳过新验证流程。

View File

@ -29,11 +29,11 @@ watch(
<div v-else id="main">
<HomeHeader v-if="!standalonePages.includes(pageName)"/>
<div id="content">
<div id="aside" v-if="!standalonePages.includes(pageName)">
<div id="aside" v-if="!standalonePages.includes(pageName) && !globalStatus.userInfos.securitySetupRequired">
<HomeAside/>
</div>
<el-drawer
v-if="!standalonePages.includes(pageName)"
v-if="!standalonePages.includes(pageName) && !globalStatus.userInfos.securitySetupRequired"
v-model="globalStatus.mobileDrawerVisible"
direction="ltr"
size="243px"
@ -49,7 +49,7 @@ watch(
'recovery-fit': pageName === 'forgot-password'
}"
>
<RouterView v-slot="{ Component }">
<RouterView v-if="!globalStatus.userInfos.securitySetupRequired || standalonePages.includes(pageName)" v-slot="{ Component }">
<transition name="page-fade" mode="out-in">
<component :is="Component" />
</transition>

View File

@ -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'))

View File

@ -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 || '外部邮箱停用失败')

View File

@ -82,7 +82,7 @@
</section>
<el-divider v-if="selectedSection === 'recovery'" class="settings-divider" />
<section v-if="selectedSection === 'recovery' && !isSuperAdmin" class="security-section">
<section v-if="selectedSection === 'recovery'" class="security-section">
<div class="settings-header security-key-header">
<div><h3>安全码</h3><p class="settings-desc">安全码只在生成时显示一次,请妥善保存。</p></div>
<div class="section-actions"><el-button type="primary" @click="generateRecoveryCodes">生成新恢复码</el-button><el-button v-if="recoveryConfigured" type="danger" plain @click="revokeRecoveryCodes">撤销</el-button></div>

View File

@ -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) {

View File

@ -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 }
}

View File

@ -8,7 +8,7 @@
:show-close="false"
class="security-setup-dialog"
>
<p class="setup-description">{{ isSuperAdmin ? '请绑定外部邮箱。未完成前,每次登录都会提醒。' : '请设置安全码并绑定外部邮箱。未完成前,每次登录都会提醒。' }}</p>
<p class="setup-description">{{ isSuperAdmin ? '请至少启用一种安全验证后继续。' : '请设置安全码并绑定外部邮箱。未完成前,每次登录都会提醒。' }}</p>
<div class="setup-options">
<section v-for="item in options" :key="item.key" class="setup-option">
<div>
@ -20,7 +20,8 @@
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<template #footer>
<el-button :loading="saving" @click="acknowledge()">我知道了</el-button>
<el-button v-if="required" :icon="SwitchButton" :loading="saving" @click="logout">注销</el-button>
<el-button v-if="!required" :loading="saving" @click="acknowledge()">我知道了</el-button>
<el-button type="primary" :loading="saving" @click="acknowledge(options[0]?.key)">去设置</el-button>
</template>
</el-dialog>
@ -28,8 +29,9 @@
<script setup>
import { usePage } from './SecuritySetupPrompt.js'
import { SwitchButton } from '@element-plus/icons-vue'
const { visible, isSuperAdmin, options, saving, error, acknowledge } = usePage()
const { visible, isSuperAdmin, required, options, saving, error, acknowledge, logout } = usePage()
</script>
<style scoped src="./SecuritySetupPrompt.css"></style>

View File

@ -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 } })