From 2f494818cfe52567da731d364069253833bbb060 Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Sat, 25 Apr 2026 21:36:08 +0800 Subject: [PATCH] fix: store trusted device token in httponly cookie --- server/internal/http/auth_handler.go | 54 +++++++++ server/internal/http/router_test.go | 169 +++++++++++++++++++++------ web/src/services/auth.ts | 41 +------ web/src/services/http.ts | 1 + 4 files changed, 193 insertions(+), 72 deletions(-) diff --git a/server/internal/http/auth_handler.go b/server/internal/http/auth_handler.go index 3c49e86..ff76c21 100644 --- a/server/internal/http/auth_handler.go +++ b/server/internal/http/auth_handler.go @@ -2,7 +2,9 @@ package http import ( "net" + stdhttp "net/http" "strings" + "time" "backupx/server/internal/apperror" "backupx/server/internal/service" @@ -10,6 +12,12 @@ import ( "github.com/gin-gonic/gin" ) +const ( + trustedDeviceCookieName = "backupx_trusted_device" + trustedDeviceCookiePath = "/api/auth" + trustedDeviceCookieMaxAge = int((30 * 24 * time.Hour) / time.Second) +) + type AuthHandler struct { authService *service.AuthService } @@ -47,11 +55,18 @@ func (h *AuthHandler) Login(c *gin.Context) { response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err)) return } + if strings.TrimSpace(input.TrustedDeviceToken) == "" { + input.TrustedDeviceToken = trustedDeviceCookieValue(c) + } payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c)) if err != nil { response.Error(c, err) return } + if payload.TrustedDeviceToken != "" { + setTrustedDeviceCookie(c, payload.TrustedDeviceToken) + payload.TrustedDeviceToken = "" + } response.Success(c, payload) } @@ -86,6 +101,7 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { response.Error(c, err) return } + clearTrustedDeviceCookie(c) response.Success(c, gin.H{"changed": true}) } @@ -146,6 +162,9 @@ func (h *AuthHandler) DisableTwoFactor(c *gin.Context) { response.Error(c, err) return } + if !user.MFAEnabled { + clearTrustedDeviceCookie(c) + } response.Success(c, user) } @@ -186,6 +205,9 @@ func (h *AuthHandler) ConfigureOTP(c *gin.Context) { response.Error(c, err) return } + if !user.MFAEnabled { + clearTrustedDeviceCookie(c) + } response.Success(c, user) } @@ -288,6 +310,9 @@ func (h *AuthHandler) DeleteWebAuthnCredential(c *gin.Context) { response.Error(c, err) return } + if !user.MFAEnabled { + clearTrustedDeviceCookie(c) + } response.Success(c, user) } @@ -322,6 +347,7 @@ func (h *AuthHandler) RevokeTrustedDevice(c *gin.Context) { response.Error(c, err) return } + clearTrustedDeviceCookie(c) response.Success(c, gin.H{"deleted": true}) } @@ -359,3 +385,31 @@ func firstForwardedValue(value string) string { } return strings.TrimSpace(parts[0]) } + +func trustedDeviceCookieValue(c *gin.Context) string { + token, err := c.Cookie(trustedDeviceCookieName) + if err != nil { + return "" + } + return strings.TrimSpace(token) +} + +func setTrustedDeviceCookie(c *gin.Context, token string) { + writeTrustedDeviceCookie(c, strings.TrimSpace(token), trustedDeviceCookieMaxAge) +} + +func clearTrustedDeviceCookie(c *gin.Context) { + writeTrustedDeviceCookie(c, "", -1) +} + +func writeTrustedDeviceCookie(c *gin.Context, value string, maxAge int) { + c.SetSameSite(stdhttp.SameSiteLaxMode) + c.SetCookie(trustedDeviceCookieName, value, maxAge, trustedDeviceCookiePath, "", requestIsSecure(c), true) +} + +func requestIsSecure(c *gin.Context) bool { + if c.Request.TLS != nil { + return true + } + return strings.EqualFold(firstForwardedValue(c.GetHeader("X-Forwarded-Proto")), "https") +} diff --git a/server/internal/http/router_test.go b/server/internal/http/router_test.go index 20aece6..f71fb41 100644 --- a/server/internal/http/router_test.go +++ b/server/internal/http/router_test.go @@ -17,9 +17,133 @@ import ( "backupx/server/internal/security" "backupx/server/internal/service" "backupx/server/internal/storage/codec" + + "github.com/pquerna/otp/totp" ) func TestSetupLoginAndProfileFlow(t *testing.T) { + router, _ := newTestHTTPRouter(t) + + setupBody, _ := json.Marshal(map[string]string{ + "username": "admin", + "password": "password-123", + "displayName": "Admin", + }) + setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody)) + setupRequest.Header.Set("Content-Type", "application/json") + setupRecorder := httptest.NewRecorder() + router.ServeHTTP(setupRecorder, setupRequest) + + if setupRecorder.Code != http.StatusOK { + t.Fatalf("expected setup 200, got %d", setupRecorder.Code) + } + + var setupResponse struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := json.Unmarshal(setupRecorder.Body.Bytes(), &setupResponse); err != nil { + t.Fatalf("unmarshal setup response: %v", err) + } + if setupResponse.Data.Token == "" { + t.Fatalf("expected token in setup response") + } + + profileRequest := httptest.NewRequest(http.MethodGet, "/api/auth/profile", nil) + profileRequest.Header.Set("Authorization", "Bearer "+setupResponse.Data.Token) + profileRecorder := httptest.NewRecorder() + router.ServeHTTP(profileRecorder, profileRequest) + + if profileRecorder.Code != http.StatusOK { + t.Fatalf("expected profile 200, got %d", profileRecorder.Code) + } +} + +func TestTrustedDeviceCookieSkipsMFA(t *testing.T) { + router, authService := newTestHTTPRouter(t) + if _, err := authService.Setup(context.Background(), service.SetupInput{ + Username: "admin", Password: "password-123", DisplayName: "Admin", + }); err != nil { + t.Fatalf("Setup error: %v", err) + } + totpSetup, err := authService.PrepareTwoFactor(context.Background(), "1", service.TwoFactorSetupInput{ + CurrentPassword: "password-123", + }) + if err != nil { + t.Fatalf("PrepareTwoFactor error: %v", err) + } + enableCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC()) + if err != nil { + t.Fatalf("GenerateCode error: %v", err) + } + if _, err := authService.EnableTwoFactor(context.Background(), "1", service.EnableTwoFactorInput{Code: enableCode}); err != nil { + t.Fatalf("EnableTwoFactor error: %v", err) + } + + loginCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC()) + if err != nil { + t.Fatalf("GenerateCode login error: %v", err) + } + loginBody, _ := json.Marshal(map[string]any{ + "username": "admin", + "password": "password-123", + "twoFactorCode": loginCode, + "rememberDevice": true, + "trustedDeviceName": "test browser", + }) + loginRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(loginBody)) + loginRequest.Header.Set("Content-Type", "application/json") + loginRecorder := httptest.NewRecorder() + router.ServeHTTP(loginRecorder, loginRequest) + + if loginRecorder.Code != http.StatusOK { + t.Fatalf("expected login 200, got %d: %s", loginRecorder.Code, loginRecorder.Body.String()) + } + trustedCookie := findCookie(loginRecorder.Result().Cookies(), trustedDeviceCookieName) + if trustedCookie == nil { + t.Fatalf("expected trusted device cookie") + } + if !trustedCookie.HttpOnly { + t.Fatalf("expected trusted device cookie to be HttpOnly") + } + if trustedCookie.Path != trustedDeviceCookiePath { + t.Fatalf("expected trusted device cookie path %q, got %q", trustedDeviceCookiePath, trustedCookie.Path) + } + var loginResponse struct { + Data struct { + Token string `json:"token"` + TrustedDeviceToken string `json:"trustedDeviceToken"` + TrustedDevice *service.TrustedDeviceOutput `json:"trustedDevice"` + } `json:"data"` + } + if err := json.Unmarshal(loginRecorder.Body.Bytes(), &loginResponse); err != nil { + t.Fatalf("unmarshal login response: %v", err) + } + if loginResponse.Data.Token == "" || loginResponse.Data.TrustedDevice == nil { + t.Fatalf("expected login token and trusted device metadata") + } + if loginResponse.Data.TrustedDeviceToken != "" { + t.Fatalf("trusted device token should not be exposed in response body") + } + + secondBody, _ := json.Marshal(map[string]string{ + "username": "admin", + "password": "password-123", + }) + secondRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(secondBody)) + secondRequest.Header.Set("Content-Type", "application/json") + secondRequest.AddCookie(trustedCookie) + secondRecorder := httptest.NewRecorder() + router.ServeHTTP(secondRecorder, secondRequest) + + if secondRecorder.Code != http.StatusOK { + t.Fatalf("expected trusted device login 200, got %d: %s", secondRecorder.Code, secondRecorder.Body.String()) + } +} + +func newTestHTTPRouter(t *testing.T) (http.Handler, *service.AuthService) { + t.Helper() tempDir := t.TempDir() cfg := config.Config{ Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"}, @@ -64,39 +188,14 @@ func TestSetupLoginAndProfileFlow(t *testing.T) { UserRepository: userRepo, SystemConfigRepo: systemConfigRepo, }) - - setupBody, _ := json.Marshal(map[string]string{ - "username": "admin", - "password": "password-123", - "displayName": "Admin", - }) - setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody)) - setupRequest.Header.Set("Content-Type", "application/json") - setupRecorder := httptest.NewRecorder() - router.ServeHTTP(setupRecorder, setupRequest) - - if setupRecorder.Code != http.StatusOK { - t.Fatalf("expected setup 200, got %d", setupRecorder.Code) - } - - var setupResponse struct { - Data struct { - Token string `json:"token"` - } `json:"data"` - } - if err := json.Unmarshal(setupRecorder.Body.Bytes(), &setupResponse); err != nil { - t.Fatalf("unmarshal setup response: %v", err) - } - if setupResponse.Data.Token == "" { - t.Fatalf("expected token in setup response") - } - - profileRequest := httptest.NewRequest(http.MethodGet, "/api/auth/profile", nil) - profileRequest.Header.Set("Authorization", "Bearer "+setupResponse.Data.Token) - profileRecorder := httptest.NewRecorder() - router.ServeHTTP(profileRecorder, profileRequest) - - if profileRecorder.Code != http.StatusOK { - t.Fatalf("expected profile 200, got %d", profileRecorder.Code) - } + return router, authService +} + +func findCookie(cookies []*http.Cookie, name string) *http.Cookie { + for _, cookie := range cookies { + if cookie.Name == name { + return cookie + } + } + return nil } diff --git a/web/src/services/auth.ts b/web/src/services/auth.ts index bdf0b7c..b7b4ad2 100644 --- a/web/src/services/auth.ts +++ b/web/src/services/auth.ts @@ -40,33 +40,8 @@ export interface AuthResult { trustedDevice?: TrustedDevice } -const TRUSTED_DEVICE_TOKEN_KEY = 'backupx-trusted-device-token' -const TRUSTED_DEVICE_TOKEN_PREFIX = 'backupx-trusted-device-token:' - -function trustedDeviceTokenKey(username: string) { - return `${TRUSTED_DEVICE_TOKEN_PREFIX}${username.trim().toLowerCase()}` -} - -export function getTrustedDeviceToken(username?: string) { - if (username?.trim()) { - return localStorage.getItem(trustedDeviceTokenKey(username)) ?? localStorage.getItem(TRUSTED_DEVICE_TOKEN_KEY) ?? '' - } - return localStorage.getItem(TRUSTED_DEVICE_TOKEN_KEY) ?? '' -} - -export function clearTrustedDeviceToken(username?: string) { - if (username?.trim()) { - localStorage.removeItem(trustedDeviceTokenKey(username)) - localStorage.removeItem(TRUSTED_DEVICE_TOKEN_KEY) - return - } - localStorage.removeItem(TRUSTED_DEVICE_TOKEN_KEY) - for (let index = localStorage.length - 1; index >= 0; index -= 1) { - const key = localStorage.key(index) - if (key?.startsWith(TRUSTED_DEVICE_TOKEN_PREFIX)) { - localStorage.removeItem(key) - } - } +export function clearTrustedDeviceToken(_username?: string) { + // 可信设备 token 由后端写入 HttpOnly cookie,前端不能也不应该读取。 } export async function fetchSetupStatus() { @@ -80,16 +55,8 @@ export async function setup(payload: SetupPayload) { } export async function login(payload: LoginPayload) { - const response = await http.post<{ code: string; message: string; data: AuthResult }>('/auth/login', { - ...payload, - trustedDeviceToken: payload.trustedDeviceToken ?? getTrustedDeviceToken(payload.username), - }) - const result = response.data.data - if (result.trustedDeviceToken) { - localStorage.setItem(trustedDeviceTokenKey(payload.username), result.trustedDeviceToken) - localStorage.removeItem(TRUSTED_DEVICE_TOKEN_KEY) - } - return result + const response = await http.post<{ code: string; message: string; data: AuthResult }>('/auth/login', payload) + return response.data.data } export async function fetchProfile() { diff --git a/web/src/services/http.ts b/web/src/services/http.ts index 5bac3d0..70891cd 100644 --- a/web/src/services/http.ts +++ b/web/src/services/http.ts @@ -12,6 +12,7 @@ let unauthorizedHandler: (() => void) | null = null export const http = axios.create({ baseURL: '/api', timeout: 10000, + withCredentials: true, }) export function setAccessToken(token: string) {