From 306af4b7b01496892697261f2cf2039f034f8f2f Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 12:27:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=A8=E6=88=B7=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BF=A1=E6=81=AF=E5=BC=B9=E7=AA=97=E4=B8=8E=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/security.go | 109 ++++++++++++++++++ backend/controllers/user.go | 7 ++ backend/listen/http_server/api_v1_compat.go | 2 + .../listen/http_server/api_v1_compat_test.go | 2 + backend/listen/http_server/http_server.go | 2 + backend/utils/csrf/csrf.go | 24 +++- backend/utils/csrf/csrf_test.go | 70 +++++++++++ .../RecoveryCodeDialog/RecoveryCodeDialog.vue | 2 + .../UserExternalEmailDialog.js | 49 ++++++-- .../UserExternalEmailDialog.vue | 4 +- .../UserManagement/UserManagement.js | 28 ++++- .../UserManagement/UserManagement.vue | 15 ++- 12 files changed, 295 insertions(+), 19 deletions(-) diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 9bd65eb..cce3528 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -728,6 +728,77 @@ func BindExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Re response.NewSuccessResponse(map[string]any{"id": item.ID, "challenge_id": challenge.ID, "status": item.Status, "email": maskEmail(input.Email), "expires_at": challenge.ExpiresAt.UTC()}).FPrint(w) } +// ChangeOwnExternalEmail 发送当前用户外部邮箱变更验证码,旧邮箱会保留到验证成功。 +func ChangeOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + runtime := currentSecurityRuntime() + if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil { + writeSecurityError(w, response.NeedLogin, "请先登录") + return + } + if !ctx.RecentAuth { + writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请确认当前密码", string(access.CodeRecentAuthRequired)) + return + } + var input externalEmailRequest + if err := decodeJSONRequest(w, req, &input); err != nil { + writeSecurityError(w, response.ParamsError, "请求无效") + return + } + normalizedEmail, err := externalemail.NormalizeEmail(input.Email) + if err != nil { + writeSecurityError(w, response.ParamsError, "请输入有效的外部邮箱地址") + return + } + owned, err := currentUserPrimaryEmail(ctx.UserID, normalizedEmail) + if err != nil { + log.WithContext(ctx).WithError(err).Error("check current user primary email failed") + writeSecurityError(w, response.ServerError, "外部邮箱变更失败") + return + } + if owned { + writeSecurityError(w, response.ParamsError, "不能将当前邮箱绑定为外部安全邮箱") + return + } + emailType := strings.TrimSpace(input.Type) + if emailType == "" { + emailType = models.ExternalEmailTypePrimary + } + item, err := runtime.external.BeginReplacement(externalemail.BindRequest{ + UserID: ctx.UserID, + Email: normalizedEmail, + Type: emailType, + }) + if err != nil { + if errors.Is(err, externalemail.ErrBindingExists) { + writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定或正在被其他账号使用") + return + } + log.WithContext(ctx).WithError(err).Error("begin own external email replacement failed") + writeSecurityError(w, response.ParamsError, "外部邮箱变更失败") + return + } + if runtime.verification.Store != nil { + runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailChange) + runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailBind) + } + challenge, err := runtime.verification.Issue(verification.IssueRequest{ + Purpose: verification.PurposeExternalEmailChange, + UserID: ctx.UserID, + Account: ctx.UserAccount, + Email: normalizedEmail, + ResourceID: item.ID, + }, remoteIP(req)) + if err != nil { + log.WithContext(ctx).WithError(err).Warn("issue own external email verification failed") + writeSecurityError(w, response.ParamsError, externalEmailVerificationIssueMessage(err)) + return + } + response.NewSuccessResponse(map[string]any{ + "binding_id": item.ID, "challenge_id": challenge.ID, + "email": maskEmail(normalizedEmail), "expires_at": challenge.ExpiresAt.UTC(), + }).FPrint(w) +} + func currentUserPrimaryEmail(userID int, email string) (bool, error) { if db.Instance == nil || userID <= 0 { return false, errors.New("database is unavailable") @@ -758,6 +829,44 @@ func VerifyExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http. response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w) } +// VerifyOwnExternalEmail 验证成功后原子替换当前用户同用途的旧邮箱。 +func VerifyOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + runtime := currentSecurityRuntime() + if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil { + writeSecurityError(w, response.NeedLogin, "请先登录") + return + } + var input verificationCheckRequest + if err := decodeJSONRequest(w, req, &input); err != nil || input.BindingID <= 0 { + writeSecurityError(w, response.ParamsError, "验证请求无效") + return + } + normalizedEmail, err := externalemail.NormalizeEmail(input.Email) + if err != nil { + writeSecurityError(w, response.ParamsError, "验证失败") + return + } + if err := runtime.verification.VerifyBound( + input.ID, + ctx.UserAccount, + normalizedEmail, + input.Code, + verification.PurposeExternalEmailChange, + input.BindingID, + remoteIP(req), + ); err != nil { + writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err)) + return + } + if err := runtime.external.VerifyReplacement(ctx.UserID, input.BindingID); err != nil { + log.WithContext(ctx).WithError(err).Error("verify own external email replacement failed") + writeSecurityError(w, response.ParamsError, "外部邮箱替换失败") + return + } + InvalidateSecurityRuntimeUser(ctx.UserID, ctx.UserAccount) + response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w) +} + func DisableExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) { runtime := currentSecurityRuntime() if ctx == nil || ctx.UserID <= 0 || runtime.external == nil { diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 45defdd..7f7c3b5 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -11,6 +11,7 @@ import ( "git.pcux.cn/xuans/Smail/services/auth" "git.pcux.cn/xuans/Smail/utils/array" "git.pcux.cn/xuans/Smail/utils/context" + "git.pcux.cn/xuans/Smail/utils/csrf" "git.pcux.cn/xuans/Smail/utils/password" "git.pcux.cn/xuans/Smail/utils/validation" log "github.com/sirupsen/logrus" @@ -204,6 +205,11 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + if _, err := csrf.Ensure(w, req, !config.DevelopmentMode); err != nil { + log.WithContext(ctx).WithError(err).Error("restore csrf token failed") + response.NewErrorResponse(response.ServerError, "登录状态恢复失败", "").FPrint(w) + return + } mailDomains := config.CurrentMailDomains() if mailDomains.Primary == "" { response.NewErrorResponse(response.ServerError, "服务配置不可用", "").FPrint(w) @@ -212,6 +218,7 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) { domains := array.Difference(mailDomains.Accepted, []string{mailDomains.Primary}) domains = append([]string{mailDomains.Primary}, domains...) result := map[string]any{ + "id": ctx.UserID, "account": ctx.UserAccount, "name": ctx.UserName, "role": ctx.EffectiveRole(), diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index e7cc4ad..c7bdd2a 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -41,6 +41,8 @@ func registerV1Routes(mux *http.ServeMux) { registerV1MethodRoute(mux, "/api/v1/security/external-email/list", "/api/security/external-email/list", http.MethodGet, 0, controllers.ListExternalEmails) registerV1MethodRoute(mux, "/api/v1/security/external-email/bind", "/api/security/external-email/bind", http.MethodPost, 16<<10, controllers.BindExternalEmail) registerV1MethodRoute(mux, "/api/v1/security/external-email/verify", "/api/security/external-email/verify", http.MethodPost, 16<<10, controllers.VerifyExternalEmail) + registerV1MethodRoute(mux, "/api/v1/security/external-email/change", "/api/security/external-email/change", http.MethodPost, 16<<10, controllers.ChangeOwnExternalEmail) + registerV1MethodRoute(mux, "/api/v1/security/external-email/change/verify", "/api/security/external-email/change/verify", http.MethodPost, 16<<10, controllers.VerifyOwnExternalEmail) registerV1MethodRoute(mux, "/api/v1/security/external-email/disable", "/api/security/external-email/disable", http.MethodPost, 16<<10, controllers.DisableExternalEmail) registerV1MethodRoute(mux, "/api/v1/logout", "/api/logout", http.MethodPost, 4<<10, controllers.Logout) registerV1MethodRoute(mux, "/api/v1/system/restart", "/api/v1/system/restart", http.MethodPost, 4<<10, controllers.RestartSmail) diff --git a/backend/listen/http_server/api_v1_compat_test.go b/backend/listen/http_server/api_v1_compat_test.go index 880a5b4..09cb0d6 100644 --- a/backend/listen/http_server/api_v1_compat_test.go +++ b/backend/listen/http_server/api_v1_compat_test.go @@ -192,6 +192,8 @@ func TestFrontendV1RoutesAreRegistered(t *testing.T) { "/api/v1/security/credentials/protocols/generate", "/api/v1/security/credentials/protocols/revoke", "/api/v1/security/external-email/bind", + "/api/v1/security/external-email/change", + "/api/v1/security/external-email/change/verify", "/api/v1/security/external-email/disable", "/api/v1/security/external-email/list", "/api/v1/security/external-email/verify", diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 6a3fae4..3194875 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -74,6 +74,8 @@ func router(mux *http.ServeMux) error { mux.HandleFunc("/api/security/external-email/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListExternalEmails))) mux.HandleFunc("/api/security/external-email/bind", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.BindExternalEmail)))) mux.HandleFunc("/api/security/external-email/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyExternalEmail)))) + mux.HandleFunc("/api/security/external-email/change", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.ChangeOwnExternalEmail)))) + mux.HandleFunc("/api/security/external-email/change/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyOwnExternalEmail)))) mux.HandleFunc("/api/security/external-email/disable", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.DisableExternalEmail)))) mux.HandleFunc("/api/logout", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.Logout)))) mux.HandleFunc("/api/group", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetUserGroup))) diff --git a/backend/utils/csrf/csrf.go b/backend/utils/csrf/csrf.go index 4cf395d..c8f9658 100644 --- a/backend/utils/csrf/csrf.go +++ b/backend/utils/csrf/csrf.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "crypto/subtle" "encoding/base64" + "errors" "net/http" "git.pcux.cn/xuans/Smail/session" @@ -29,6 +30,20 @@ func Issue(w http.ResponseWriter, r *http.Request, secure bool) (string, error) return token, nil } +// Ensure 为已认证会话恢复双提交 CSRF Cookie。 +// Cookie 缺失或与服务端会话不一致时轮换令牌,避免继续信任过期的浏览器状态。 +func Ensure(w http.ResponseWriter, r *http.Request, secure bool) (string, error) { + if session.Instance == nil { + return "", errors.New("session manager is unavailable") + } + stored := session.Instance.GetString(r.Context(), SessionKey) + cookie, err := r.Cookie(CookieName) + if err == nil && stored != "" && constantTimeEqual(stored, cookie.Value) { + return stored, nil + } + return Issue(w, r, secure) +} + func Clear(w http.ResponseWriter, r *http.Request, secure bool) { if session.Instance != nil { session.Instance.Remove(r.Context(), SessionKey) @@ -49,9 +64,12 @@ func Valid(r *http.Request) bool { return false } stored := session.Instance.GetString(r.Context(), SessionKey) - if stored == "" || len(stored) != len(header) || len(cookie.Value) != len(header) { + if stored == "" { return false } - return subtle.ConstantTimeCompare([]byte(stored), []byte(header)) == 1 && - subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) == 1 + return constantTimeEqual(stored, header) && constantTimeEqual(cookie.Value, header) +} + +func constantTimeEqual(left, right string) bool { + return len(left) == len(right) && subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 } diff --git a/backend/utils/csrf/csrf_test.go b/backend/utils/csrf/csrf_test.go index 9f1efc6..269b89d 100644 --- a/backend/utils/csrf/csrf_test.go +++ b/backend/utils/csrf/csrf_test.go @@ -76,3 +76,73 @@ func TestClearInvalidatesExistingToken(t *testing.T) { t.Fatalf("unexpected CSRF clear cookie: %+v", cookies) } } + +func TestEnsureRestoresMissingCookieWithRotatedToken(t *testing.T) { + old := session.Instance + manager := scs.New() + session.Instance = manager + t.Cleanup(func() { session.Instance = old }) + + request := testRequestWithToken(t, manager, "old-token", "", "") + recorder := httptest.NewRecorder() + token, err := Ensure(recorder, request, true) + if err != nil { + t.Fatal(err) + } + if token == "" || token == "old-token" { + t.Fatal("missing cookie did not rotate the CSRF token") + } + if stored := manager.GetString(request.Context(), SessionKey); stored != token { + t.Fatalf("rotated token was not stored in session: %q", stored) + } + cookies := recorder.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("unexpected CSRF cookies: %+v", cookies) + } + cookie := cookies[0] + if cookie.Name != CookieName || cookie.Value != token || cookie.Path != "/" || + cookie.MaxAge != 86400 || !cookie.Secure || cookie.HttpOnly || + cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("unexpected restored CSRF cookie: %+v", cookie) + } +} + +func TestEnsureKeepsMatchingTokenWithoutReissuingCookie(t *testing.T) { + old := session.Instance + manager := scs.New() + session.Instance = manager + t.Cleanup(func() { session.Instance = old }) + + request := testRequestWithToken(t, manager, "current-token", "", "current-token") + recorder := httptest.NewRecorder() + token, err := Ensure(recorder, request, false) + if err != nil { + t.Fatal(err) + } + if token != "current-token" { + t.Fatalf("matching token was unexpectedly rotated: %q", token) + } + if cookies := recorder.Result().Cookies(); len(cookies) != 0 { + t.Fatalf("matching token unexpectedly reissued cookies: %+v", cookies) + } +} + +func TestEnsureRotatesMismatchedCookie(t *testing.T) { + old := session.Instance + manager := scs.New() + session.Instance = manager + t.Cleanup(func() { session.Instance = old }) + + request := testRequestWithToken(t, manager, "stored-token", "", "stale-token") + recorder := httptest.NewRecorder() + token, err := Ensure(recorder, request, false) + if err != nil { + t.Fatal(err) + } + if token == "" || token == "stored-token" || token == "stale-token" { + t.Fatalf("mismatched cookie did not rotate token: %q", token) + } + if cookies := recorder.Result().Cookies(); len(cookies) != 1 || cookies[0].Secure { + t.Fatalf("unexpected development CSRF cookie: %+v", cookies) + } +} diff --git a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.vue b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.vue index 88652e8..b6498bc 100644 --- a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.vue +++ b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.vue @@ -6,6 +6,8 @@ :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" + append-to-body + destroy-on-close class="recovery-code-dialog" @update:model-value="value => { if (!value) close() }" > diff --git a/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.js b/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.js index bce4449..15af897 100644 --- a/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.js +++ b/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.js @@ -15,6 +15,10 @@ export const userExternalEmailDialogProps = { type: Object, default: null, }, + selfManaged: { + type: Boolean, + default: false, + }, } const verificationCodeLength = 8 @@ -30,6 +34,21 @@ function assertSuccess(response, fallback) { return response } +export function externalEmailEndpoints(selfManaged) { + if (selfManaged) { + return { + list: '/api/v1/security/external-email/list', + change: '/api/v1/security/external-email/change', + verify: '/api/v1/security/external-email/change/verify', + } + } + return { + list: '/api/v1/user/security/external-email/list', + change: '/api/v1/user/security/external-email/change', + verify: '/api/v1/user/security/external-email/verify', + } +} + export function useUserExternalEmailDialog(props, emit) { const form = reactive({ email: '', type: 'primary', code: '' }) const challenge = reactive({ id: '', bindingId: 0, maskedEmail: '', expiresAt: '' }) @@ -47,6 +66,7 @@ export function useUserExternalEmailDialog(props, emit) { set: value => emit('update:modelValue', value), }) const busy = computed(() => sending.value || verifying.value) + const endpoints = computed(() => externalEmailEndpoints(props.selfManaged)) const hasChallenge = computed(() => Boolean(challenge.id && challenge.bindingId)) const remainingSeconds = computed(() => { if (!challenge.expiresAt) return 0 @@ -93,12 +113,13 @@ export function useUserExternalEmailDialog(props, emit) { if (!props.user?.ID) return false try { const response = await requestWithRecentAuthentication(async () => { - const result = await http.get('/api/v1/user/security/external-email/list', { - params: { id: props.user.ID }, - }) + const config = props.selfManaged ? undefined : { params: { id: props.user.ID } } + const result = await http.get(endpoints.value.list, config) return assertSuccess(result, '读取外部邮箱失败') }) - const items = Array.isArray(response.data?.items) ? response.data.items : [] + const items = props.selfManaged + ? (Array.isArray(response.data) ? response.data : []) + : (Array.isArray(response.data?.items) ? response.data.items : []) const primary = items.find(item => item.type === 'primary') || items[0] currentEmail.value = primary?.email || '' if (primary?.type) form.type = primary.type @@ -122,11 +143,12 @@ export function useUserExternalEmailDialog(props, emit) { clearMessages() try { const response = await requestWithRecentAuthentication(async () => { - const result = await http.post('/api/v1/user/security/external-email/change', { - user_id: props.user.ID, + const payload = { email, type: form.type, - }) + } + if (!props.selfManaged) payload.user_id = props.user.ID + const result = await http.post(endpoints.value.change, payload) return assertSuccess(result, '验证码发送失败') }) if (!response.data?.challenge_id || !response.data?.binding_id) { @@ -169,13 +191,18 @@ export function useUserExternalEmailDialog(props, emit) { clearMessages() try { const response = await requestWithRecentAuthentication(async () => { - const result = await http.post('/api/v1/user/security/external-email/verify', { - user_id: props.user.ID, - challenge_id: challenge.id, + const payload = { binding_id: challenge.bindingId, email: form.email, code, - }) + } + if (props.selfManaged) { + payload.id = challenge.id + } else { + payload.user_id = props.user.ID + payload.challenge_id = challenge.id + } + const result = await http.post(endpoints.value.verify, payload) return assertSuccess(result, '外部邮箱验证失败') }) emit('changed') diff --git a/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.vue b/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.vue index db7f372..09908a4 100644 --- a/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.vue +++ b/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.vue @@ -1,11 +1,13 @@