增加配置页就地身份确认
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-07 17:13:06 +08:00
parent 77abc51a9d
commit 659d98b589
10 changed files with 426 additions and 13 deletions

View File

@ -0,0 +1,73 @@
package controllers
import (
"net/http"
"strconv"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/session"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus"
)
const (
reauthUserFailureLimit = 10
reauthIPFailureLimit = 50
)
var (
reauthUserAttempts = newLoginLimiter(reauthUserFailureLimit)
reauthIPAttempts = newLoginLimiter(reauthIPFailureLimit)
)
type reauthRequest struct {
Password string `json:"password"`
}
// ReauthenticateCurrentUser 在不离开当前页面的情况下重新确认当前会话用户密码。
func ReauthenticateCurrentUser(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
if ctx == nil || ctx.UserID <= 0 || session.Instance == nil {
writeReauthError(w, http.StatusUnauthorized, "REAUTH_SESSION_REQUIRED", "登录状态已失效,请重新登录")
return
}
var input reauthRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.Password == "" {
writeReauthError(w, http.StatusBadRequest, "REAUTH_REQUEST_INVALID", "请输入当前密码")
return
}
defer func() { input.Password = "" }()
userKey := reauthUserAttempts.accountKey(strconv.Itoa(ctx.UserID))
ipKey := reauthIPAttempts.ipKey(req)
if reauthUserAttempts.blocked(userKey) || reauthIPAttempts.blocked(ipKey) {
log.WithContext(ctx).Info("reauthentication rejected by rate limit")
writeReauthError(w, http.StatusTooManyRequests, "REAUTH_RATE_LIMITED", "尝试次数过多,请稍后再试")
return
}
user, err := auth.AuthenticateUserByID(ctx.UserID, input.Password)
if err != nil {
reauthUserAttempts.recordFailure(userKey)
reauthIPAttempts.recordFailure(ipKey)
log.WithContext(ctx).Info("reauthentication failed")
writeReauthError(w, http.StatusUnauthorized, "REAUTH_FAILED", "当前密码不正确")
return
}
if err := session.RefreshRecentAuthentication(req.Context(), user); err != nil {
log.WithContext(ctx).WithError(err).Warn("refresh recent authentication failed")
writeReauthError(w, http.StatusUnauthorized, "REAUTH_SESSION_CHANGED", "登录状态已变化,请重新登录")
return
}
reauthUserAttempts.clear(userKey)
response.NewSuccessResponse(map[string]any{"reauthenticated": true}).FPrint(w)
}
func writeReauthError(w http.ResponseWriter, status int, code string, message string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
response.NewErrorResponse(response.ConfigErrorNumber(status), message, response.ConfigErrorData{Code: code}).FPrint(w)
}

View File

@ -0,0 +1,144 @@
package controllers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/session"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
"github.com/alexedwards/scs/v2"
)
func TestReauthenticateCurrentUser(t *testing.T) {
oldConfig := config.Instance
oldDB := db.Instance
oldSession := session.Instance
oldUserAttempts := reauthUserAttempts
oldIPAttempts := reauthIPAttempts
t.Cleanup(func() {
if db.Instance != nil && db.Instance != oldDB {
_ = db.Instance.Close()
}
config.Instance = oldConfig
db.Instance = oldDB
session.Instance = oldSession
reauthUserAttempts = oldUserAttempts
reauthIPAttempts = oldIPAttempts
})
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "reauth.db")}
if err := db.Init(""); err != nil {
t.Fatal(err)
}
user := models.User{Account: "reauth-user", Name: "续验用户", SessionVersion: 1, SecurityVersion: 1}
if err := auth.SetPassword(&user, "CorrectP12"); err != nil {
t.Fatal(err)
}
if _, err := db.Instance.Insert(&user); err != nil {
t.Fatal(err)
}
reauthUserAttempts = newLoginLimiter(reauthUserFailureLimit)
reauthIPAttempts = newLoginLimiter(reauthIPFailureLimit)
manager := scs.New()
loaded, err := manager.Load(httptest.NewRequest(http.MethodGet, "/", nil).Context(), "")
if err != nil {
t.Fatal(err)
}
session.Instance = manager
if err := session.PutUser(loaded, &user); err != nil {
t.Fatal(err)
}
identity, err := session.GetUser(loaded)
if err != nil {
t.Fatal(err)
}
identity.AuthenticatedAt = time.Now().Add(-time.Hour).Unix()
encoded, err := json.Marshal(identity)
if err != nil {
t.Fatal(err)
}
manager.Put(loaded, "user", string(encoded))
ctx := &appcontext.Context{Context: loaded, UserID: user.ID, UserAccount: user.Account}
status, failed := invokeReauth(t, ctx, `{"password":"WrongPass12"}`)
if status != http.StatusUnauthorized || failed.ErrorNo == 0 {
t.Fatalf("incorrect password response: status=%d body=%+v", status, failed)
}
unchanged, err := session.GetUser(loaded)
if err != nil || unchanged.AuthenticatedAt != identity.AuthenticatedAt {
t.Fatalf("failed reauthentication changed session: identity=%+v err=%v", unchanged, err)
}
status, succeeded := invokeReauth(t, ctx, `{"password":"CorrectP12"}`)
if status != http.StatusOK || succeeded.ErrorNo != 0 {
t.Fatalf("correct password response: status=%d body=%+v", status, succeeded)
}
refreshed, err := session.GetUser(loaded)
if err != nil || !refreshed.HasRecentAuthentication(time.Now().UTC()) {
t.Fatalf("recent authentication was not refreshed: identity=%+v err=%v", refreshed, err)
}
if _, err := db.Instance.ID(user.ID).Cols("disabled").Update(&models.User{Disabled: 1}); err != nil {
t.Fatal(err)
}
status, disabled := invokeReauth(t, ctx, `{"password":"CorrectP12"}`)
if status != http.StatusUnauthorized || disabled.ErrorNo == 0 {
t.Fatalf("disabled user was reauthenticated: status=%d body=%+v", status, disabled)
}
}
func TestReauthenticateCurrentUserRequiresSessionAndRateLimitsFailures(t *testing.T) {
oldUserAttempts := reauthUserAttempts
oldIPAttempts := reauthIPAttempts
oldSession := session.Instance
t.Cleanup(func() {
reauthUserAttempts = oldUserAttempts
reauthIPAttempts = oldIPAttempts
session.Instance = oldSession
})
request := httptest.NewRequest(http.MethodPost, "/api/v1/security/reauth", strings.NewReader(`{"password":"AnyPass12"}`))
recorder := httptest.NewRecorder()
ReauthenticateCurrentUser(&appcontext.Context{}, recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("missing session status = %d", recorder.Code)
}
reauthUserAttempts = newLoginLimiter(1)
reauthIPAttempts = newLoginLimiter(10)
reauthUserAttempts.recordFailure(reauthUserAttempts.accountKey("7"))
ctx := &appcontext.Context{UserID: 7, UserAccount: "limited"}
request = httptest.NewRequest(http.MethodPost, "/api/v1/security/reauth", strings.NewReader(`{"password":"AnyPass12"}`))
request.RemoteAddr = "192.0.2.50:1234"
recorder = httptest.NewRecorder()
session.Instance = scs.New()
ReauthenticateCurrentUser(ctx, recorder, request)
if recorder.Code != http.StatusTooManyRequests || !strings.Contains(recorder.Body.String(), "REAUTH_RATE_LIMITED") {
t.Fatalf("rate limit response: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func invokeReauth(t *testing.T, ctx *appcontext.Context, body string) (int, response.Response) {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "/api/v1/security/reauth", strings.NewReader(body))
request.RemoteAddr = "192.0.2.10:1234"
request = request.WithContext(ctx.Context)
recorder := httptest.NewRecorder()
ReauthenticateCurrentUser(ctx, recorder, request)
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid response: %v body=%s", err, recorder.Body.String())
}
return recorder.Code, result
}

View File

@ -13,6 +13,7 @@ import (
func registerV1Routes(mux *http.ServeMux) {
registerV1MethodRoute(mux, "/api/v1/security/setup/acknowledge", "/api/security/setup/acknowledge", http.MethodPost, 4<<10, controllers.AcknowledgeSecuritySetup)
registerV1MethodRoute(mux, "/api/v1/login", "/api/login", http.MethodPost, 64<<10, controllers.Login)
registerV1MethodRoute(mux, "/api/v1/security/reauth", "/api/security/reauth", http.MethodPost, 4<<10, controllers.ReauthenticateCurrentUser)
registerV1MethodRoute(mux, "/api/v1/security/verification/request", "/api/security/verification/request", http.MethodPost, 16<<10, controllers.RequestVerification)
registerV1MethodRoute(mux, "/api/v1/security/verification/verify", "/api/security/verification/verify", http.MethodPost, 16<<10, controllers.VerifyVerification)
registerV1MethodRoute(mux, "/api/v1/security/login/verify", "/api/security/login/verify", http.MethodPost, 16<<10, controllers.CompleteLoginVerification)

View File

@ -46,6 +46,7 @@ func router(mux *http.ServeMux) error {
mux.HandleFunc("/.well-known/", controllers.AcmeChallenge)
mux.HandleFunc("/api/ping", controllers.Ping)
mux.HandleFunc("/api/login", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.Login))))
mux.HandleFunc("/api/security/reauth", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.ReauthenticateCurrentUser))))
mux.HandleFunc("/api/security/verification/request", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RequestVerification))))
mux.HandleFunc("/api/security/verification/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyVerification))))
mux.HandleFunc("/api/security/login/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.CompleteLoginVerification))))

View File

@ -48,6 +48,11 @@ func Authenticate(account, plain string) (*models.User, error) {
return authenticateLoadedUser(&user, has, err, plain)
}
// AuthenticateUserByID 使用当前会话用户 ID 校验密码,避免重新认证时切换账号。
func AuthenticateUserByID(userID int, plain string) (*models.User, error) {
return authenticateUserByID(userID, plain)
}
func authenticateUserByID(userID int, plain string) (*models.User, error) {
if userID <= 0 {
if plain != "" {

View File

@ -58,6 +58,34 @@ func PutUser(ctx context.Context, user *models.User) error {
return nil
}
// RefreshRecentAuthentication 仅续期当前会话的高风险操作认证时间,不创建新会话。
func RefreshRecentAuthentication(ctx context.Context, user *models.User) error {
if Instance == nil || user == nil || user.ID <= 0 {
return errors.New("session user is invalid")
}
identity, err := GetUser(ctx)
if err != nil {
return err
}
if identity.ID != user.ID || identity.Account != user.Account ||
identity.SessionVersion != user.SessionVersion ||
(identity.SecurityVersion > 0 && identity.SecurityVersion != user.SecurityVersion) {
return errors.New("session user changed")
}
identity.Name = user.Name
identity.Role = user.EffectiveRole()
identity.IsAdmin = user.IsAdmin
identity.SecurityVersion = user.SecurityVersion
identity.MustChangePassword = user.MustChangePassword
identity.AuthenticatedAt = time.Now().UTC().Unix()
encoded, err := json.Marshal(identity)
if err != nil {
return err
}
Instance.Put(ctx, userSessionKey, string(encoded))
return nil
}
func GetUser(ctx context.Context) (UserIdentity, error) {
var identity UserIdentity
if Instance == nil {

View File

@ -0,0 +1,49 @@
import { ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
const recentAuthenticationRequired = 'RECENT_AUTH_REQUIRED'
const cancelledCode = 'RECENT_AUTH_CANCELLED'
export function isRecentAuthenticationCancelled(error) {
return error?.code === cancelledCode
}
export async function requestWithRecentAuthentication(request) {
try {
return await request()
} catch (error) {
if (error?.apiErrorCode !== recentAuthenticationRequired) throw error
}
let password = ''
try {
const result = await ElMessageBox.prompt(
'为保护敏感配置,请确认当前登录密码。确认后将自动继续应用。',
'确认当前密码',
{
inputType: 'password',
inputPlaceholder: '请输入当前密码',
confirmButtonText: '确认并继续',
cancelButtonText: '取消',
closeOnClickModal: false,
inputValidator: value => String(value || '').length > 0 || '请输入当前密码'
}
)
password = String(result.value || '')
} catch (error) {
const cancelled = new Error('已取消身份确认')
cancelled.code = cancelledCode
throw cancelled
}
try {
const response = await http.post('/api/v1/security/reauth', { password })
if (!response || response.errorNo !== 0) {
throw new Error(response?.errorMsg || '身份确认失败')
}
} finally {
password = ''
}
return request()
}

View File

@ -2,6 +2,7 @@ import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication'
import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigForm/ConfigForm.js'
const sections = [
@ -566,7 +567,9 @@ export function usePage(pageKey = 'base') {
working.value = 'save'
try {
const request = buildRequest()
const response = await http.put(`/api/v1/config/${apiSection()}`, request)
const response = await requestWithRecentAuthentication(
() => http.put(`/api/v1/config/${apiSection()}`, request)
)
notifyResponseError(response, '配置保存失败')
savedGeneration.value = response.data?.generation || 0
pendingCandidate.value = { ...response.data, section: apiSection(), sameSession: true }
@ -577,6 +580,7 @@ export function usePage(pageKey = 'base') {
if (!quiet) ElMessage.success('配置已保存,等待应用')
return true
} catch (error) {
if (isRecentAuthenticationCancelled(error)) return false
ElMessage.error(error.message || '配置保存失败')
return false
} finally {
@ -593,10 +597,13 @@ export function usePage(pageKey = 'base') {
}
working.value = 'apply'
try {
const response = await http.post(`/api/v1/config/${apiSection()}/apply`, {
generation: savedGeneration.value,
candidateId: pendingCandidate.value?.candidateId
})
const response = await requestWithRecentAuthentication(() => http.post(
`/api/v1/config/${apiSection()}/apply`,
{
generation: savedGeneration.value,
candidateId: pendingCandidate.value?.candidateId
}
))
notifyResponseError(response, '配置应用失败')
savedGeneration.value = 0
await Promise.all([loadStatus(), loadSection()])
@ -604,6 +611,7 @@ export function usePage(pageKey = 'base') {
ElMessage.success(successMessage)
return true
} catch (error) {
if (isRecentAuthenticationCancelled(error)) return false
ElMessage.error(error.message || '配置应用失败')
return false
} finally {

View File

@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication'
const defaultSettings = {
url: '',
@ -121,7 +122,9 @@ export function useSpamBlockSettings() {
if (!canWrite() || loading.value || working.value || errorMessage.value || pendingCandidate.value) return false
working.value = 'save'
try {
const response = await http.put('/api/v1/config/plugins', buildRequest())
const response = await requestWithRecentAuthentication(
() => http.put('/api/v1/config/plugins', buildRequest())
)
notifyResponseError(response, '插件配置保存失败')
savedGeneration.value = Number(response.data?.generation || 0)
pendingCandidate.value = { ...response.data, section: 'plugins', sameSession: true }
@ -131,6 +134,7 @@ export function useSpamBlockSettings() {
if (!quiet) ElMessage.success('垃圾邮件插件配置已保存,等待应用')
return true
} catch (error) {
if (isRecentAuthenticationCancelled(error)) return false
ElMessage.error(error.message || '插件配置保存失败')
return false
} finally {
@ -146,16 +150,20 @@ export function useSpamBlockSettings() {
}
working.value = 'apply'
try {
const response = await http.post('/api/v1/config/plugins/apply', {
generation: savedGeneration.value,
candidateId: pendingCandidate.value?.candidateId
})
const response = await requestWithRecentAuthentication(() => http.post(
'/api/v1/config/plugins/apply',
{
generation: savedGeneration.value,
candidateId: pendingCandidate.value?.candidateId
}
))
notifyResponseError(response, '插件配置应用失败')
savedGeneration.value = 0
await load()
ElMessage.success(successMessage)
return true
} catch (error) {
if (isRecentAuthenticationCancelled(error)) return false
ElMessage.error(error.message || '垃圾邮件插件配置应用失败')
return false
} finally {

View File

@ -7,12 +7,31 @@ import { computed, reactive, ref } from './vue.mjs'
const formSource = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js', import.meta.url), 'utf8')
const formHelpers = compileFunction(formSource.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, updateFormConfig, validateFormConfig }')()
const recentAuthenticationSource = readFileSync(new URL('../../frontend/src/utils/recentAuthentication.js', import.meta.url), 'utf8')
.replace(/^import .*$/gm, '')
.replaceAll('export function ', 'function ')
const compileRecentAuthentication = compileFunction(
recentAuthenticationSource + '\nreturn { isRecentAuthenticationCancelled, requestWithRecentAuthentication }',
['ElMessageBox', 'http']
)
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8')
const compilePage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage(pageKey)',
['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig']
['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'isRecentAuthenticationCancelled', 'requestWithRecentAuthentication']
)
const createPageWithKey = (...args) => compilePage(...args, formHelpers.readFormConfig, formHelpers.updateFormConfig, formHelpers.validateFormConfig)
const createPageWithKey = (...args) => {
const http = args[6]
const messageBox = args[5]
const recentAuthentication = compileRecentAuthentication(messageBox, http)
return compilePage(
...args,
formHelpers.readFormConfig,
formHelpers.updateFormConfig,
formHelpers.validateFormConfig,
recentAuthentication.isRecentAuthenticationCancelled,
recentAuthentication.requestWithRecentAuthentication
)
}
const createPage = (...args) => createPageWithKey(...args, 'base', () => {})
function fixture(get, pageKey = 'base', options = {}) {
@ -20,7 +39,10 @@ function fixture(get, pageKey = 'base', options = {}) {
let dispose = () => {}
const page = createPageWithKey(computed, () => {}, reactive, ref,
{ error: message => messages.push(message), warning: message => messages.push(message), success: () => {} },
{ confirm: options.confirm || (async () => {}) }, {
{
confirm: options.confirm || (async () => {}),
prompt: options.prompt || (async () => { throw 'cancel' })
}, {
get: path => path.endsWith('/pending') && !options.pendingGet ? Promise.resolve({ errorNo: 0, data: null }) : get(path),
...options.http
},
@ -465,6 +487,80 @@ test('保存并应用一次点击按候选协议连续完成保存和应用', as
assert.equal(page.savedGeneration.value, 0)
})
test('最近认证过期时在当前页确认密码并自动重试保存和应用', async () => {
const requests = []
let pending = null
let saveAttempts = 0
let prompts = 0
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const recentAuthError = Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
const { page } = fixture(async () => ({ errorNo: 0, data: active }), 'mail', {
prompt: async () => { prompts++; return { value: 'CurrentPass12' } },
http: {
get: async path => ({
errorNo: 0,
data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 1 } : active
}),
put: async (path, body) => {
requests.push(path)
saveAttempts++
if (saveAttempts === 1) throw recentAuthError
pending = { generation: 2, candidateId: 'reauth-candidate', section: 'mail', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
requests.push(path)
if (path.endsWith('/reauth')) {
assert.deepEqual(body, { password: 'CurrentPass12' })
return { errorNo: 0, data: { reauthenticated: true } }
}
pending = null
return { errorNo: 0, data: { generation: 2 } }
}
}
})
await page.loadAll()
page.updateField(['listener', 'port'], 2525)
await page.saveAndApply()
assert.equal(prompts, 1)
assert.deepEqual(requests, [
'/api/v1/config/mail',
'/api/v1/security/reauth',
'/api/v1/config/mail',
'/api/v1/config/mail/apply'
])
})
test('取消最近身份确认时保留配置输入且不重试请求', async () => {
let saves = 0
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {}, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page, messages } = fixture(async path => ({
errorNo: 0,
data: path.endsWith('/status') ? { activeGeneration: 1 } : active
}), 'mail', {
prompt: async () => { throw 'cancel' },
http: {
put: async () => {
saves++
throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
}
}
})
await page.loadAll()
page.updateField(['listener', 'port'], 2525)
await page.saveAndApply()
assert.equal(saves, 1)
assert.equal(page.formConfig.value.listener.port, 2525)
assert.deepEqual(messages, [])
})
test('配置编辑区移除填写标题、说明与重复标签,保留主标题和必要提示', () => {
const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url)
const template = readFileSync(new URL('ConfigEditor/ConfigEditor.vue', root), 'utf8')