修复用户安全信息弹窗与会话校验

This commit is contained in:
smail 2026-09-08 12:27:03 +08:00
parent 31ee54942a
commit 306af4b7b0
12 changed files with 295 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
<template>
<el-dialog
:model-value="visible"
title="编辑外部邮箱"
:title="selfManaged ? '编辑我的外部邮箱' : '编辑外部邮箱'"
width="min(520px, calc(100vw - 32px))"
:close-on-click-modal="false"
:close-on-press-escape="!busy"
:show-close="!busy"
append-to-body
destroy-on-close
class="user-external-email-dialog"
@update:model-value="value => { if (!value) close() }"
>

View File

@ -1,4 +1,4 @@
import {reactive, ref} from 'vue'
import {computed, reactive, ref} from 'vue'
import lang from '../../../i18n/i18n';
@ -8,6 +8,8 @@ import {ElNotification} from "element-plus";
import {Plus} from "@element-plus/icons-vue";
import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
const roleOptions = {
super_admin: { label: '超级管理员', type: 'danger' },
admin: { label: '管理员', type: 'primary' },
@ -39,7 +41,23 @@ export function accountStatusMeta(disabled) {
: { label: '正常', type: 'success' }
}
export function isSameUser(selectedUser, currentUser) {
const selectedID = Number(selectedUser?.ID || 0)
const currentID = Number(currentUser?.id || 0)
if (selectedID > 0 && currentID > 0) {
return selectedID === currentID
}
return Boolean(selectedUser?.Account && currentUser?.account)
&& selectedUser.Account === currentUser.account
}
export function canManageRecoveryCode(user) {
return Boolean(user?.ID) && user.Role !== 'super_admin'
}
export function usePage() {
const globalStatus = useGlobalStatusStore()
const userList = reactive([])
const currentPage = ref(1)
@ -55,6 +73,10 @@ export function usePage() {
const recoveryCodeDialogVisible = ref(false)
const externalEmailDialogVisible = ref(false)
const editingCurrentUser = computed(() => isSameUser(selectedUser.value, globalStatus.userInfos))
const recoveryCodeWebManaged = computed(() => canManageRecoveryCode(selectedUser.value))
const editUserInfo = reactive({
"account": "",
@ -101,7 +123,7 @@ export function usePage() {
}
const openRecoveryCodeDialog = function () {
if (!selectedUser.value?.ID) return
if (!recoveryCodeWebManaged.value) return
recoveryCodeDialogVisible.value = true
}
@ -174,6 +196,8 @@ export function usePage() {
editModel,
editUserInfo,
selectedUser,
editingCurrentUser,
recoveryCodeWebManaged,
recoveryCodeDialogVisible,
externalEmailDialogVisible,
openRecoveryCodeDialog,

View File

@ -115,9 +115,17 @@
<div class="security-edit-item">
<div>
<strong>安全码</strong>
<span>{{ recoveryCodeMeta(selectedUser?.RecoveryCodeConfigured).label }}</span>
<span v-if="recoveryCodeWebManaged">
{{ recoveryCodeMeta(selectedUser?.RecoveryCodeConfigured).label }}
</span>
<span v-else>部署端管理</span>
</div>
<el-button @click="openRecoveryCodeDialog">管理安全码</el-button>
<el-button
:disabled="!recoveryCodeWebManaged"
@click="openRecoveryCodeDialog"
>
{{ recoveryCodeWebManaged ? '管理安全码' : '仅支持本机管理' }}
</el-button>
</div>
<div class="security-edit-item">
<div>
@ -164,6 +172,7 @@
<UserExternalEmailDialog
v-model="externalEmailDialogVisible"
:user="selectedUser"
:self-managed="editingCurrentUser"
@changed="handleSecurityChanged"
/>
</div>
@ -188,6 +197,8 @@ const {
editModel,
editUserInfo,
selectedUser,
editingCurrentUser,
recoveryCodeWebManaged,
recoveryCodeDialogVisible,
externalEmailDialogVisible,
openRecoveryCodeDialog,