diff --git a/server/admin/internal/modules/auth/handler.go b/server/admin/internal/modules/auth/handler.go index b4657c31..d66a45d7 100644 --- a/server/admin/internal/modules/auth/handler.go +++ b/server/admin/internal/modules/auth/handler.go @@ -12,7 +12,11 @@ import ( authcore "hyapp-admin-server/internal/service" ) -const refreshCookieName = "hyapp_admin_refresh" +const ( + refreshCookieName = "hyapp_admin_refresh" + refreshCookiePath = "/" + legacyRefreshCookiePath = "/api/v1/auth" +) type Handler struct { service *AuthFlowService @@ -52,7 +56,7 @@ func (h *Handler) Login(c *gin.Context) { } func (h *Handler) Logout(c *gin.Context) { - if token, err := c.Cookie(refreshCookieName); err == nil && token != "" { + for _, token := range refreshCookieCandidates(c) { _ = h.service.Logout(token) } h.setRefreshCookie(c, "", -1) @@ -60,28 +64,32 @@ func (h *Handler) Logout(c *gin.Context) { } func (h *Handler) Refresh(c *gin.Context) { - token, err := c.Cookie(refreshCookieName) - if err != nil || token == "" { + tokens := refreshCookieCandidates(c) + if len(tokens) == 0 { response.Unauthorized(c, "刷新会话不存在") return } - result, err := h.service.Refresh(token, LoginInput{ - IP: c.ClientIP(), - UserAgent: c.Request.UserAgent(), - }) - if err != nil { - response.Unauthorized(c, "刷新会话已失效") - return + var result LoginResult + var err error + for _, token := range tokens { + result, err = h.service.Refresh(token, LoginInput{ + IP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + }) + if err == nil { + h.setRefreshCookie(c, result.RefreshToken, int(h.cfg.RefreshTokenTTL.Seconds())) + response.OK(c, gin.H{ + "accessToken": result.AccessToken, + "expiresAtMs": result.ExpiresAtMS, + "user": shared.UserDTO(result.User), + "permissions": result.Permissions, + }) + return + } } - h.setRefreshCookie(c, result.RefreshToken, int(h.cfg.RefreshTokenTTL.Seconds())) - response.OK(c, gin.H{ - "accessToken": result.AccessToken, - "expiresAtMs": result.ExpiresAtMS, - "user": shared.UserDTO(result.User), - "permissions": result.Permissions, - }) + response.Unauthorized(c, "刷新会话已失效") } func (h *Handler) Me(c *gin.Context) { @@ -114,7 +122,24 @@ func (h *Handler) ChangePassword(c *gin.Context) { func (h *Handler) setRefreshCookie(c *gin.Context, token string, maxAge int) { c.SetSameSite(refreshCookieSameSite(h.cfg.RefreshCookieSameSite)) - c.SetCookie(refreshCookieName, token, maxAge, "/api/v1/auth", "", h.cfg.RefreshCookieSecure, true) + c.SetCookie(refreshCookieName, token, maxAge, refreshCookiePath, "", h.cfg.RefreshCookieSecure, true) + c.SetCookie(refreshCookieName, "", -1, legacyRefreshCookiePath, "", h.cfg.RefreshCookieSecure, true) +} + +func refreshCookieCandidates(c *gin.Context) []string { + values := make([]string, 0, 2) + seen := map[string]struct{}{} + for _, cookie := range c.Request.Cookies() { + if cookie.Name != refreshCookieName || cookie.Value == "" { + continue + } + if _, ok := seen[cookie.Value]; ok { + continue + } + seen[cookie.Value] = struct{}{} + values = append(values, cookie.Value) + } + return values } func refreshCookieSameSite(value string) http.SameSite { diff --git a/server/admin/internal/modules/auth/handler_test.go b/server/admin/internal/modules/auth/handler_test.go new file mode 100644 index 00000000..27da8da1 --- /dev/null +++ b/server/admin/internal/modules/auth/handler_test.go @@ -0,0 +1,95 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "hyapp-admin-server/internal/config" + authcore "hyapp-admin-server/internal/service" +) + +func TestRefreshUsesRootCookieAndClearsLegacyCookiePath(t *testing.T) { + gin.SetMode(gin.TestMode) + store := newFakeAuthStore(t) + handler := testAuthHandler(store) + login, err := handler.service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.1", UserAgent: "browser"}) + if err != nil { + t.Fatalf("login: %v", err) + } + router := gin.New() + router.POST("/api/v1/auth/refresh", handler.Refresh) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil) + req.AddCookie(&http.Cookie{Name: refreshCookieName, Value: login.RefreshToken}) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("refresh status = %d body=%s", rec.Code, rec.Body.String()) + } + setCookies := rec.Result().Cookies() + if !hasCookieWithPath(setCookies, refreshCookieName, refreshCookiePath) { + t.Fatalf("refresh must set root refresh cookie: %+v", setCookies) + } + if !hasExpiredCookieWithPath(setCookies, refreshCookieName, legacyRefreshCookiePath) { + t.Fatalf("refresh must clear legacy path cookie: %+v", setCookies) + } +} + +func TestRefreshTriesDuplicateRefreshCookiesUntilOneWorks(t *testing.T) { + gin.SetMode(gin.TestMode) + store := newFakeAuthStore(t) + handler := testAuthHandler(store) + first, err := handler.service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.1", UserAgent: "browser"}) + if err != nil { + t.Fatalf("login: %v", err) + } + refreshed, err := handler.service.Refresh(first.RefreshToken, LoginInput{IP: "127.0.0.1", UserAgent: "browser"}) + if err != nil { + t.Fatalf("prime refresh: %v", err) + } + router := gin.New() + router.POST("/api/v1/auth/refresh", handler.Refresh) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil) + req.Header.Set("Cookie", refreshCookieName+"="+first.RefreshToken+"; "+refreshCookieName+"="+refreshed.RefreshToken) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("refresh status = %d body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "刷新会话已失效") { + t.Fatalf("refresh should skip stale duplicate cookie: %s", rec.Body.String()) + } +} + +func testAuthHandler(store *fakeAuthStore) *Handler { + cfg := config.Config{RefreshTokenTTL: 7 * 24 * time.Hour, RefreshCookieSameSite: "lax"} + return &Handler{ + service: NewService(store, authcore.NewAuthService("test-secret", time.Hour), cfg), + cfg: cfg, + } +} + +func hasCookieWithPath(cookies []*http.Cookie, name string, path string) bool { + for _, cookie := range cookies { + if cookie.Name == name && cookie.Path == path && cookie.MaxAge >= 0 && cookie.Value != "" { + return true + } + } + return false +} + +func hasExpiredCookieWithPath(cookies []*http.Cookie, name string, path string) bool { + for _, cookie := range cookies { + if cookie.Name == name && cookie.Path == path && cookie.MaxAge < 0 { + return true + } + } + return false +}