This commit is contained in:
parent
de8c298bd5
commit
67931eab5d
@ -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 "请求无效或当前平台不支持"
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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("管理员恢复通道运行失败")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
49
backend/services/adminreset/flow_test.go
Normal file
49
backend/services/adminreset/flow_test.go
Normal 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 })
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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:
|
||||
|
||||
278
backend/services/adminreset/verification.go
Normal file
278
backend/services/adminreset/verification.go
Normal 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(¤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)
|
||||
}
|
||||
}
|
||||
302
backend/services/adminreset/verification_test.go
Normal file
302
backend/services/adminreset/verification_test.go
Normal 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
|
||||
}
|
||||
@ -228,6 +228,8 @@ func CodeMessage(purpose Purpose, code string) (string, string) {
|
||||
purposeText = "完成密码恢复验证"
|
||||
case PurposePasswordChange:
|
||||
purposeText = "完成修改密码安全验证"
|
||||
case PurposeAdminLocalReset:
|
||||
purposeText = "完成超级管理员本机密码重置验证"
|
||||
case PurposeExternalEmailBind:
|
||||
purposeText = "完成外部邮箱绑定验证"
|
||||
case PurposeExternalEmailChange:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -30,5 +30,5 @@ RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
TimeoutStartSec=180s
|
||||
TimeoutStartSec=330s
|
||||
TimeoutStopSec=5s
|
||||
|
||||
@ -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`;安全响应形状测试 |
|
||||
|
||||
|
||||
41
docs/超级管理员安全验证与本机重置.md
Normal file
41
docs/超级管理员安全验证与本机重置.md
Normal 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 单元的超时时间。第一版客户端不能跳过新验证流程。
|
||||
@ -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>
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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 || '外部邮箱停用失败')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 } })
|
||||
|
||||
Loading…
Reference in New Issue
Block a user