Compare commits
2 Commits
86ae6db83f
...
44266c17de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44266c17de | ||
|
|
52b45d9b3d |
@ -25,6 +25,7 @@ cors_allowed_origins:
|
|||||||
- "http://hyapp-platform.global-interaction.com"
|
- "http://hyapp-platform.global-interaction.com"
|
||||||
- "https://hyapp-platform.global-interaction.com"
|
- "https://hyapp-platform.global-interaction.com"
|
||||||
refresh_cookie_secure: true
|
refresh_cookie_secure: true
|
||||||
|
refresh_cookie_same_site: "none"
|
||||||
bootstrap:
|
bootstrap:
|
||||||
# 仅首次初始化或显式 -bootstrap 时打开;生产常驻进程不要每次启动重放 seed。
|
# 仅首次初始化或显式 -bootstrap 时打开;生产常驻进程不要每次启动重放 seed。
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@ -26,6 +26,7 @@ cors_allowed_origins:
|
|||||||
- "http://localhost:7002"
|
- "http://localhost:7002"
|
||||||
- "http://127.0.0.1:7002"
|
- "http://127.0.0.1:7002"
|
||||||
refresh_cookie_secure: false
|
refresh_cookie_secure: false
|
||||||
|
refresh_cookie_same_site: "lax"
|
||||||
bootstrap:
|
bootstrap:
|
||||||
enabled: true
|
enabled: true
|
||||||
seed_demo_data: true
|
seed_demo_data: true
|
||||||
|
|||||||
@ -13,32 +13,33 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServiceName string `yaml:"service_name"`
|
ServiceName string `yaml:"service_name"`
|
||||||
NodeID string `yaml:"node_id"`
|
NodeID string `yaml:"node_id"`
|
||||||
Environment string `yaml:"environment"`
|
Environment string `yaml:"environment"`
|
||||||
Log logging.Config `yaml:"log"`
|
Log logging.Config `yaml:"log"`
|
||||||
HTTPAddr string `yaml:"http_addr"`
|
HTTPAddr string `yaml:"http_addr"`
|
||||||
MySQLDSN string `yaml:"mysql_dsn"`
|
MySQLDSN string `yaml:"mysql_dsn"`
|
||||||
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
||||||
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||||
Migrations MigrationConfig `yaml:"migrations"`
|
Migrations MigrationConfig `yaml:"migrations"`
|
||||||
JWTSecret string `yaml:"jwt_secret"`
|
JWTSecret string `yaml:"jwt_secret"`
|
||||||
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
|
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
|
||||||
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
|
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
|
||||||
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
||||||
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
|
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
|
||||||
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
|
||||||
BootstrapPassword string `yaml:"bootstrap_password"`
|
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
||||||
UserService UserServiceConfig `yaml:"user_service"`
|
BootstrapPassword string `yaml:"bootstrap_password"`
|
||||||
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
|
UserService UserServiceConfig `yaml:"user_service"`
|
||||||
Redis RedisConfig `yaml:"redis"`
|
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
|
||||||
Jobs JobsConfig `yaml:"jobs"`
|
Redis RedisConfig `yaml:"redis"`
|
||||||
WalletService WalletServiceConfig `yaml:"wallet_service"`
|
Jobs JobsConfig `yaml:"jobs"`
|
||||||
RoomService RoomServiceConfig `yaml:"room_service"`
|
WalletService WalletServiceConfig `yaml:"wallet_service"`
|
||||||
ActivityService ActivityServiceConfig `yaml:"activity_service"`
|
RoomService RoomServiceConfig `yaml:"room_service"`
|
||||||
GameService GameServiceConfig `yaml:"game_service"`
|
ActivityService ActivityServiceConfig `yaml:"activity_service"`
|
||||||
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
GameService GameServiceConfig `yaml:"game_service"`
|
||||||
|
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MigrationConfig struct {
|
type MigrationConfig struct {
|
||||||
@ -135,7 +136,8 @@ func Default() Config {
|
|||||||
"http://localhost:7001",
|
"http://localhost:7001",
|
||||||
"http://127.0.0.1:7001",
|
"http://127.0.0.1:7001",
|
||||||
},
|
},
|
||||||
RefreshCookieSecure: false,
|
RefreshCookieSecure: false,
|
||||||
|
RefreshCookieSameSite: "lax",
|
||||||
Bootstrap: BootstrapConfig{
|
Bootstrap: BootstrapConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
SeedDemoData: true,
|
SeedDemoData: true,
|
||||||
@ -225,6 +227,10 @@ func (cfg *Config) Normalize() {
|
|||||||
if cfg.Log.MaxPayloadBytes <= 0 {
|
if cfg.Log.MaxPayloadBytes <= 0 {
|
||||||
cfg.Log.MaxPayloadBytes = 2048
|
cfg.Log.MaxPayloadBytes = 2048
|
||||||
}
|
}
|
||||||
|
cfg.RefreshCookieSameSite = strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite))
|
||||||
|
if cfg.RefreshCookieSameSite == "" {
|
||||||
|
cfg.RefreshCookieSameSite = "lax"
|
||||||
|
}
|
||||||
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
||||||
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
||||||
if cfg.NodeID == "" {
|
if cfg.NodeID == "" {
|
||||||
@ -332,6 +338,14 @@ func (cfg Config) Validate() error {
|
|||||||
if cfg.RefreshTokenTTL <= 0 {
|
if cfg.RefreshTokenTTL <= 0 {
|
||||||
return errors.New("refresh_token_ttl must be greater than 0")
|
return errors.New("refresh_token_ttl must be greater than 0")
|
||||||
}
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(cfg.RefreshCookieSameSite)) {
|
||||||
|
case "lax", "strict", "none":
|
||||||
|
default:
|
||||||
|
return errors.New("refresh_cookie_same_site must be one of lax, strict, none")
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(cfg.RefreshCookieSameSite), "none") && !cfg.RefreshCookieSecure {
|
||||||
|
return errors.New("refresh_cookie_secure must be true when refresh_cookie_same_site is none")
|
||||||
|
}
|
||||||
if isLockedEnvironment(cfg.Environment) {
|
if isLockedEnvironment(cfg.Environment) {
|
||||||
if cfg.JWTSecret == "dev-shared-secret" || len(cfg.JWTSecret) < 32 {
|
if cfg.JWTSecret == "dev-shared-secret" || len(cfg.JWTSecret) < 32 {
|
||||||
return errors.New("jwt_secret must be a non-default value with at least 32 characters outside local/dev")
|
return errors.New("jwt_secret must be a non-default value with at least 32 characters outside local/dev")
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"hyapp-admin-server/internal/config"
|
"hyapp-admin-server/internal/config"
|
||||||
@ -11,7 +12,11 @@ import (
|
|||||||
authcore "hyapp-admin-server/internal/service"
|
authcore "hyapp-admin-server/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
const refreshCookieName = "hyapp_admin_refresh"
|
const (
|
||||||
|
refreshCookieName = "hyapp_admin_refresh"
|
||||||
|
refreshCookiePath = "/"
|
||||||
|
legacyRefreshCookiePath = "/api/v1/auth"
|
||||||
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *AuthFlowService
|
service *AuthFlowService
|
||||||
@ -51,7 +56,7 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Logout(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.service.Logout(token)
|
||||||
}
|
}
|
||||||
h.setRefreshCookie(c, "", -1)
|
h.setRefreshCookie(c, "", -1)
|
||||||
@ -59,28 +64,32 @@ func (h *Handler) Logout(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Refresh(c *gin.Context) {
|
func (h *Handler) Refresh(c *gin.Context) {
|
||||||
token, err := c.Cookie(refreshCookieName)
|
tokens := refreshCookieCandidates(c)
|
||||||
if err != nil || token == "" {
|
if len(tokens) == 0 {
|
||||||
response.Unauthorized(c, "刷新会话不存在")
|
response.Unauthorized(c, "刷新会话不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.service.Refresh(token, LoginInput{
|
var result LoginResult
|
||||||
IP: c.ClientIP(),
|
var err error
|
||||||
UserAgent: c.Request.UserAgent(),
|
for _, token := range tokens {
|
||||||
})
|
result, err = h.service.Refresh(token, LoginInput{
|
||||||
if err != nil {
|
IP: c.ClientIP(),
|
||||||
response.Unauthorized(c, "刷新会话已失效")
|
UserAgent: c.Request.UserAgent(),
|
||||||
return
|
})
|
||||||
|
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.Unauthorized(c, "刷新会话已失效")
|
||||||
response.OK(c, gin.H{
|
|
||||||
"accessToken": result.AccessToken,
|
|
||||||
"expiresAtMs": result.ExpiresAtMS,
|
|
||||||
"user": shared.UserDTO(result.User),
|
|
||||||
"permissions": result.Permissions,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Me(c *gin.Context) {
|
func (h *Handler) Me(c *gin.Context) {
|
||||||
@ -112,6 +121,34 @@ func (h *Handler) ChangePassword(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) setRefreshCookie(c *gin.Context, token string, maxAge int) {
|
func (h *Handler) setRefreshCookie(c *gin.Context, token string, maxAge int) {
|
||||||
c.SetSameSite(http.SameSiteLaxMode)
|
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 {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "strict":
|
||||||
|
return http.SameSiteStrictMode
|
||||||
|
case "none":
|
||||||
|
return http.SameSiteNoneMode
|
||||||
|
default:
|
||||||
|
return http.SameSiteLaxMode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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