fix: handle stale admin refresh cookies
This commit is contained in:
parent
52b45d9b3d
commit
44266c17de
@ -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 {
|
||||
|
||||
95
server/admin/internal/modules/auth/handler_test.go
Normal file
95
server/admin/internal/modules/auth/handler_test.go
Normal file
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user