mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-04 23:16:41 +08:00
fix: store trusted device token in httponly cookie
This commit is contained in:
@@ -2,7 +2,9 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
stdhttp "net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
"backupx/server/internal/apperror"
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
@@ -10,6 +12,12 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
trustedDeviceCookieName = "backupx_trusted_device"
|
||||||
|
trustedDeviceCookiePath = "/api/auth"
|
||||||
|
trustedDeviceCookieMaxAge = int((30 * 24 * time.Hour) / time.Second)
|
||||||
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
}
|
}
|
||||||
@@ -47,11 +55,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
|
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(input.TrustedDeviceToken) == "" {
|
||||||
|
input.TrustedDeviceToken = trustedDeviceCookieValue(c)
|
||||||
|
}
|
||||||
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
|
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if payload.TrustedDeviceToken != "" {
|
||||||
|
setTrustedDeviceCookie(c, payload.TrustedDeviceToken)
|
||||||
|
payload.TrustedDeviceToken = ""
|
||||||
|
}
|
||||||
response.Success(c, payload)
|
response.Success(c, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +101,7 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
clearTrustedDeviceCookie(c)
|
||||||
response.Success(c, gin.H{"changed": true})
|
response.Success(c, gin.H{"changed": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +162,9 @@ func (h *AuthHandler) DisableTwoFactor(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !user.MFAEnabled {
|
||||||
|
clearTrustedDeviceCookie(c)
|
||||||
|
}
|
||||||
response.Success(c, user)
|
response.Success(c, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +205,9 @@ func (h *AuthHandler) ConfigureOTP(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !user.MFAEnabled {
|
||||||
|
clearTrustedDeviceCookie(c)
|
||||||
|
}
|
||||||
response.Success(c, user)
|
response.Success(c, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +310,9 @@ func (h *AuthHandler) DeleteWebAuthnCredential(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !user.MFAEnabled {
|
||||||
|
clearTrustedDeviceCookie(c)
|
||||||
|
}
|
||||||
response.Success(c, user)
|
response.Success(c, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +347,7 @@ func (h *AuthHandler) RevokeTrustedDevice(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
clearTrustedDeviceCookie(c)
|
||||||
response.Success(c, gin.H{"deleted": true})
|
response.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,3 +385,31 @@ func firstForwardedValue(value string) string {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(parts[0])
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,9 +17,133 @@ import (
|
|||||||
"backupx/server/internal/security"
|
"backupx/server/internal/security"
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/internal/storage/codec"
|
"backupx/server/internal/storage/codec"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetupLoginAndProfileFlow(t *testing.T) {
|
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()
|
tempDir := t.TempDir()
|
||||||
cfg := config.Config{
|
cfg := config.Config{
|
||||||
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
|
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
|
||||||
@@ -64,39 +188,14 @@ func TestSetupLoginAndProfileFlow(t *testing.T) {
|
|||||||
UserRepository: userRepo,
|
UserRepository: userRepo,
|
||||||
SystemConfigRepo: systemConfigRepo,
|
SystemConfigRepo: systemConfigRepo,
|
||||||
})
|
})
|
||||||
|
return router, authService
|
||||||
|
}
|
||||||
|
|
||||||
setupBody, _ := json.Marshal(map[string]string{
|
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
||||||
"username": "admin",
|
for _, cookie := range cookies {
|
||||||
"password": "password-123",
|
if cookie.Name == name {
|
||||||
"displayName": "Admin",
|
return cookie
|
||||||
})
|
}
|
||||||
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 nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,33 +40,8 @@ export interface AuthResult {
|
|||||||
trustedDevice?: TrustedDevice
|
trustedDevice?: TrustedDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRUSTED_DEVICE_TOKEN_KEY = 'backupx-trusted-device-token'
|
export function clearTrustedDeviceToken(_username?: string) {
|
||||||
const TRUSTED_DEVICE_TOKEN_PREFIX = 'backupx-trusted-device-token:'
|
// 可信设备 token 由后端写入 HttpOnly cookie,前端不能也不应该读取。
|
||||||
|
|
||||||
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 async function fetchSetupStatus() {
|
export async function fetchSetupStatus() {
|
||||||
@@ -80,16 +55,8 @@ export async function setup(payload: SetupPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function login(payload: LoginPayload) {
|
export async function login(payload: LoginPayload) {
|
||||||
const response = await http.post<{ code: string; message: string; data: AuthResult }>('/auth/login', {
|
const response = await http.post<{ code: string; message: string; data: AuthResult }>('/auth/login', payload)
|
||||||
...payload,
|
return response.data.data
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProfile() {
|
export async function fetchProfile() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ let unauthorizedHandler: (() => void) | null = null
|
|||||||
export const http = axios.create({
|
export const http = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function setAccessToken(token: string) {
|
export function setAccessToken(token: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user