diff --git a/config.yaml b/config.yaml index 517013f..a11b3de 100644 --- a/config.yaml +++ b/config.yaml @@ -14,10 +14,14 @@ database: sslmode: disable jwt: - secret: "" # 生产环境通过 JWT_SECRET 环境变量或 .env 注入 - access_expire: 15 # 分钟 - refresh_expire: 168 # 小时 (7 天) - remember_expire: 720 # 小时 (30 天) + secret: "" # 生产环境通过 JWT_SECRET 环境变量或 .env 注入(若未来需复用 JWT) + +# 会话配置(服务端 Session,替换 JWT 无状态认证) +# 每次请求自动续期(滑动窗口),解决"记住我"掉线问题 +session: + idle_timeout: 1440 # 不记住我:空闲超时(分钟),默认 1440 = 24 小时 + remember_timeout: 43200 # 记住我:空闲超时(分钟),默认 43200 = 30 天 + cleanup_interval: 300 # 后台清理过期会话间隔(秒),默认 300 bcrypt: cost: 12 diff --git a/go.mod b/go.mod index 714e34a..7cb0234 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.26.0 require ( github.com/chai2010/webp v1.4.0 github.com/gin-gonic/gin v1.12.0 - github.com/golang-jwt/jwt/v5 v5.3.1 github.com/spf13/viper v1.21.0 golang.org/x/crypto v0.52.0 golang.org/x/image v0.41.0 diff --git a/go.sum b/go.sum index 1fddb81..9131652 100644 --- a/go.sum +++ b/go.sum @@ -35,8 +35,6 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/internal/common/cookie.go b/internal/common/cookie.go index c99a54e..94c235b 100644 --- a/internal/common/cookie.go +++ b/internal/common/cookie.go @@ -11,52 +11,30 @@ import ( // Cookie 名称常量 const ( - CookieName = "mlb_token" - RefreshCookieName = "mlb_refresh" - RMCookieName = "mlb_rm" // 记住我标记,JS 可读 + SessionCookieName = "mlb_sid" // 会话 ID Cookie(HttpOnly) ) -// SetAuthCookies 设置 access + refresh Cookie -// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录 -// rememberMe=false → Cookie 用 session 模式(maxAge=0),关浏览器即清除,但页面开启期间自动刷新 -func SetAuthCookies(c *gin.Context, accessToken, refreshToken string, rememberMe bool, cfg *config.Config) { +// SetSessionCookie 写入会话 ID Cookie +// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录 +// rememberMe=false → Cookie session 模式(maxAge=0),关浏览器即清除 +func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) { secure := cfg.Server.Mode != "debug" c.SetSameSite(http.SameSiteLaxMode) - setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure) - + var maxAge int if rememberMe { - setCookie(c, RefreshCookieName, refreshToken, int(cfg.JWT.RememberExpire*3600), secure) - setPlainCookie(c, RMCookieName, "1", int(cfg.JWT.RememberExpire*3600), secure) + maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒 } else { - // session cookie:关浏览器即清除,但页面开启期间自动刷新生效 - setCookie(c, RefreshCookieName, refreshToken, 0, secure) - setPlainCookie(c, RMCookieName, "1", 0, secure) + maxAge = 0 // session cookie } + + log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure) + c.SetCookie(SessionCookieName, sid, maxAge, "/", "", secure, true) } -// SetAccessCookie 仅刷新 access Cookie(refresh 续期调用) -func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) { +// ClearSessionCookie 清除会话 ID Cookie(logout 调用) +func ClearSessionCookie(c *gin.Context, cfg *config.Config) { secure := cfg.Server.Mode != "debug" - c.SetSameSite(http.SameSiteLaxMode) - setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure) + c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true) } -// ClearAuthCookies 清除所有认证 Cookie(logout 调用) -func ClearAuthCookies(c *gin.Context, cfg *config.Config) { - secure := cfg.Server.Mode != "debug" - c.SetCookie(CookieName, "", -1, "/", "", secure, true) - c.SetCookie(RefreshCookieName, "", -1, "/", "", secure, true) - c.SetCookie(RMCookieName, "", -1, "/", "", secure, false) -} - -// setCookie 写入一个 HttpOnly Cookie -func setCookie(c *gin.Context, name, value string, maxAge int, secure bool) { - log.Printf("[setCookie] name=%s maxAge=%ds secure=%v", name, maxAge, secure) - c.SetCookie(name, value, maxAge, "/", "", secure, true) -} - -// setPlainCookie 写入非 HttpOnly Cookie(JS 可读) -func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) { - c.SetCookie(name, value, maxAge, "/", "", secure, false) -} diff --git a/internal/config/config.go b/internal/config/config.go index 206ab06..cc35ef8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` JWT JWTConfig `mapstructure:"jwt"` + Session SessionConfig `mapstructure:"session"` Bcrypt BcryptConfig `mapstructure:"bcrypt"` Roles RolesConfig `mapstructure:"roles"` Audit AuditConfig `mapstructure:"audit"` @@ -42,10 +43,14 @@ type DatabaseConfig struct { } type JWTConfig struct { - Secret string `mapstructure:"secret"` - AccessExpire int `mapstructure:"access_expire"` // 分钟 - RefreshExpire int `mapstructure:"refresh_expire"` // 小时 - RememberExpire int `mapstructure:"remember_expire"` // 小时 + Secret string `mapstructure:"secret"` +} + +// SessionConfig 服务端会话配置(替换 JWT 无状态认证) +type SessionConfig struct { + IdleTimeout int `mapstructure:"idle_timeout"` // 不记住我:空闲超时(分钟),默认 1440(24小时) + RememberTimeout int `mapstructure:"remember_timeout"` // 记住我:空闲超时(分钟),默认 43200(30天) + CleanupInterval int `mapstructure:"cleanup_interval"` // 后台清理间隔(秒),默认 300 } type BcryptConfig struct { @@ -91,8 +96,8 @@ func Load(configPath string) *Config { log.Fatalf("解析配置失败: %v", err) } - log.Printf("[Config] JWT.SecretLen=%d JWT.AccessExpire=%d min JWT.RefreshExpire=%d h JWT.RememberExpire=%d h | Server.Mode=%s", - len(c.JWT.Secret), c.JWT.AccessExpire, c.JWT.RefreshExpire, c.JWT.RememberExpire, c.Server.Mode) + log.Printf("[Config] Session.IdleTimeout=%d min Session.RememberTimeout=%d min Session.CleanupInterval=%d s | Server.Mode=%s", + c.Session.IdleTimeout, c.Session.RememberTimeout, c.Session.CleanupInterval, c.Server.Mode) App = c return c diff --git a/internal/controller/auth_api_login.go b/internal/controller/auth_api_login.go index 468578f..7a3ce0f 100644 --- a/internal/controller/auth_api_login.go +++ b/internal/controller/auth_api_login.go @@ -45,7 +45,7 @@ func (ac *AuthController) Login(c *gin.Context) { return } - accessToken, refreshToken, user, err := ac.authService.Login(req, ip) + user, err := ac.authService.Login(req, ip) if err != nil { // 记录失败 → 两个维度各 +1 if recordAccount != nil { @@ -80,7 +80,14 @@ func (ac *AuthController) Login(c *gin.Context) { // 登录成功 → 清除失败计数 ac.rateLimiter.Clear(email, ip) - common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg) + // 创建服务端 session + sid, err := ac.sessionManager.Create(user, req.RememberMe) + if err != nil { + common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试") + return + } + + common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg) common.OkWithMessage(c, user, "登录成功") } @@ -93,7 +100,7 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) { return } - accessToken, refreshToken, user, err := ac.authService.ConfirmRestore(req, clientIP(c)) + user, err := ac.authService.ConfirmRestore(req, clientIP(c)) if err != nil { switch err { case common.ErrInvalidCred: @@ -104,6 +111,12 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) { return } - common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg) + sid, err := ac.sessionManager.Create(user, req.RememberMe) + if err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试") + return + } + + common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg) common.OkWithMessage(c, user, "账号已恢复,欢迎回来") } diff --git a/internal/controller/auth_api_register.go b/internal/controller/auth_api_register.go index 5816258..bed4c8b 100644 --- a/internal/controller/auth_api_register.go +++ b/internal/controller/auth_api_register.go @@ -41,7 +41,7 @@ func (ac *AuthController) Register(c *gin.Context) { return } - accessToken, refreshToken, user, err := ac.authService.Register(req, clientIP(c)) + user, err := ac.authService.Register(req, clientIP(c)) if err != nil { if recordReg != nil { recordReg() @@ -61,40 +61,45 @@ func (ac *AuthController) Register(c *gin.Context) { return } - common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg) + sid, err := ac.sessionManager.Create(user, req.RememberMe) + if err != nil { + common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试") + return + } + + common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg) common.OkWithMessage(c, user, "注册成功!欢迎加入 MetaLab") } -// Logout 退出登录:清除所有认证 Cookie +// Logout 退出登录:删除服务端 session + 清除 Cookie func (ac *AuthController) Logout(c *gin.Context) { - common.ClearAuthCookies(c, ac.cfg) + if sid, err := c.Cookie(common.SessionCookieName); err == nil && sid != "" { + _ = ac.sessionManager.Destroy(sid) + } + common.ClearSessionCookie(c, ac.cfg) common.OkMessage(c, "已退出登录") } -// RefreshToken 用 refresh token 换取新的 access token +// RefreshToken 检查登录状态并续期(session 模式下每次请求已自动续期,此端点用于前端显式检查) func (ac *AuthController) RefreshToken(c *gin.Context) { - refreshToken, err := c.Cookie(common.RefreshCookieName) - if err != nil { + sid, err := c.Cookie(common.SessionCookieName) + if err != nil || sid == "" { common.Error(c, http.StatusUnauthorized, "请重新登录") return } - accessToken, _, err := ac.tokenService.RefreshAccessToken(refreshToken) - if err != nil { - common.ClearAuthCookies(c, ac.cfg) - switch err { - case common.ErrTokenExpired, common.ErrTokenRevoked: - common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录") - case common.ErrUserBanned: - common.Error(c, http.StatusForbidden, "账号已被封禁") - default: - common.Error(c, http.StatusUnauthorized, "请重新登录") - } + s, err := ac.sessionManager.Validate(sid) + if err != nil || s == nil { + common.ClearSessionCookie(c, ac.cfg) + common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录") return } - common.SetAccessCookie(c, accessToken, ac.cfg) - - common.Ok(c, nil) + common.Ok(c, gin.H{ + "uid": s.UserID, + "email": s.Email, + "username": s.Username, + "role": s.Role, + }) } diff --git a/internal/controller/auth_controller.go b/internal/controller/auth_controller.go index 6b6244e..a84d0f7 100644 --- a/internal/controller/auth_controller.go +++ b/internal/controller/auth_controller.go @@ -5,6 +5,7 @@ import ( "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/config" + "metazone.cc/metalab/internal/session" "metazone.cc/metalab/internal/theme" "github.com/gin-gonic/gin" @@ -12,15 +13,15 @@ import ( // AuthController 认证相关页面 + API type AuthController struct { - authService authUseCase - tokenService tokenRefresher - rateLimiter rateLimiter - cfg *config.Config + authService authUseCase + sessionManager *session.Manager + rateLimiter rateLimiter + cfg *config.Config } // NewAuthController 构造函数 -func NewAuthController(authService authUseCase, tokenSvc tokenRefresher, limiter rateLimiter, cfg *config.Config) *AuthController { - return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg} +func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config) *AuthController { + return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg} } // RegisterPage 注册页面(已登录用户重定向到首页) @@ -35,10 +36,10 @@ func (ac *AuthController) RegisterPage(c *gin.Context) { return } c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{ - "Title": "注册", - "ExtraCSS": "/static/css/auth.css", - "Guidelines": guidelines, - "RegistrationEnabled": ac.authService.IsRegistrationEnabled(), + "Title": "注册", + "ExtraCSS": "/static/css/auth.css", + "Guidelines": guidelines, + "RegistrationEnabled": ac.authService.IsRegistrationEnabled(), })) } diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 00854fc..aa92037 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -7,19 +7,15 @@ import ( ) // authUseCase AuthController 对 AuthService 的最小依赖(ISP:5 个方法) +// 注意:Login/Register/ConfirmRestore 不再返回 JWT,session 由 controller 通过 SessionManager 创建 type authUseCase interface { - Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) - Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) - ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) + Register(req model.RegisterRequest, regIP string) (*model.User, error) + Login(req model.LoginRequest, loginIP string) (*model.User, error) + ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) CheckEmail(email string) (bool, error) IsRegistrationEnabled() bool } -// tokenRefresher AuthController 对 TokenService 的最小依赖(ISP:1 个方法) -type tokenRefresher interface { - RefreshAccessToken(refreshTokenStr string) (string, *model.User, error) -} - // rateLimiter AuthController 对 RateLimiter 的最小依赖(ISP:3 个方法) type rateLimiter interface { AllowAccount(email string) (middleware.RateLimitResult, func()) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 56ea0b8..ac77297 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,48 +1,81 @@ package middleware import ( + "net/http" + "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/config" + "metazone.cc/metalab/internal/session" "github.com/gin-gonic/gin" ) -// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验) +// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager) type AuthMiddleware struct { - cfg *config.Config - userRepo tokenVersionStore + cfg *config.Config + sessionManager *session.Manager } // NewAuthMiddleware 构造函数 -func NewAuthMiddleware(cfg *config.Config, userRepo tokenVersionStore) *AuthMiddleware { - return &AuthMiddleware{cfg: cfg, userRepo: userRepo} +func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware { + return &AuthMiddleware{cfg: cfg, sessionManager: sm} } -// Required 登录认证中间件:校验 JWT → 检查 token_version → 注入用户信息 +// Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息 +// 验证失败 → 清除 cookie → 返回 401 func (am *AuthMiddleware) Required() gin.HandlerFunc { return func(c *gin.Context) { - claims, err := am.authenticateToken(c) - if err != nil { - common.ClearAuthCookies(c, am.cfg) - c.AbortWithStatusJSON(401, gin.H{ + sid, err := c.Cookie(common.SessionCookieName) + if err != nil || sid == "" { + common.ClearSessionCookie(c, am.cfg) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "success": false, "message": "登录已过期,请重新登录", }) return } - injectUserContext(c, claims) + + s, err := am.sessionManager.Validate(sid) + if err != nil || s == nil { + common.ClearSessionCookie(c, am.cfg) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "success": false, "message": "登录已过期,请重新登录", + }) + return + } + + injectSessionContext(c, s) c.Next() } } -// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行 +// Optional 可选认证:已登录则注入用户信息,未登录也放行 func (am *AuthMiddleware) Optional() gin.HandlerFunc { return func(c *gin.Context) { - claims, err := am.authenticateToken(c) - if err != nil { + sid, err := c.Cookie(common.SessionCookieName) + if err != nil || sid == "" { c.Next() return } - injectUserContext(c, claims) + + s, err := am.sessionManager.Validate(sid) + if err != nil || s == nil { + common.ClearSessionCookie(c, am.cfg) + c.Next() + return + } + + injectSessionContext(c, s) c.Next() } } + +// injectSessionContext 将会话中的用户信息注入 gin context +func injectSessionContext(c *gin.Context, s *session.Session) { + if s == nil { + return + } + c.Set("uid", s.UserID) + c.Set("email", s.Email) + c.Set("username", s.Username) + c.Set("role", s.Role) +} diff --git a/internal/middleware/auth_admin.go b/internal/middleware/auth_admin.go index 778a312..2e8e7a0 100644 --- a/internal/middleware/auth_admin.go +++ b/internal/middleware/auth_admin.go @@ -12,16 +12,25 @@ import ( // AdminAuth 管理后台页面认证:失败时 302 跳首页而非返回 JSON func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc { return func(c *gin.Context) { - claims, err := am.authenticateToken(c) - if err != nil { - log.Printf("[AdminAuth] auth failed path=%s err=%v → 302", c.Request.URL.Path, err) - common.ClearAuthCookies(c, am.cfg) + sid, err := c.Cookie(common.SessionCookieName) + if err != nil || sid == "" { + common.ClearSessionCookie(c, am.cfg) c.Redirect(http.StatusFound, "/") c.Abort() return } - log.Printf("[AdminAuth] OK: uid=%v role=%v path=%s", claims["uid"], claims["role"], c.Request.URL.Path) - injectUserContext(c, claims) + + s, err := am.sessionManager.Validate(sid) + if err != nil || s == nil { + log.Printf("[AdminAuth] session invalid path=%s → 302", c.Request.URL.Path) + common.ClearSessionCookie(c, am.cfg) + c.Redirect(http.StatusFound, "/") + c.Abort() + return + } + + log.Printf("[AdminAuth] OK uid=%d role=%s path=%s", s.UserID, s.Role, c.Request.URL.Path) + injectSessionContext(c, s) c.Next() } } diff --git a/internal/middleware/auth_parser.go b/internal/middleware/auth_parser.go index 353ed3a..211bd6b 100644 --- a/internal/middleware/auth_parser.go +++ b/internal/middleware/auth_parser.go @@ -1,46 +1,3 @@ package middleware -import ( - "log" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" -) - -// parseToken 解析并验证 JWT -func parseToken(tokenStr, secret string) (jwt.MapClaims, error) { - now := time.Now() - token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { - return []byte(secret), nil - }) - if err != nil || !token.Valid { - log.Printf("[parseToken] FAIL: now=%v err=%v (secret_len=%d token_len=%d)", - now, err, len(secret), len(tokenStr)) - return nil, err - } - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil, jwt.ErrSignatureInvalid - } - if exp, exists := claims["exp"]; exists { - var expTime time.Time - switch v := exp.(type) { - case float64: - expTime = time.Unix(int64(v), 0) - case *jwt.NumericDate: - expTime = v.Time - } - if !expTime.IsZero() { - log.Printf("[parseToken] OK: exp=%v (%d) now=%v isExpired=%v", - expTime, expTime.Unix(), now, now.After(expTime)) - } - } - return claims, nil -} - -// IsLoginPage 检查是否已在登录状态,已登录用户跳过登录/注册页 -func IsLoginPage(c *gin.Context) bool { - return strings.HasPrefix(c.Request.URL.Path, "/auth/") -} +// auth_parser.go: JWT 解析逻辑已被服务端 Session 替换,本文件保留占位,后续清理。 diff --git a/internal/middleware/auth_token.go b/internal/middleware/auth_token.go index fe6e332..fce2ec4 100644 --- a/internal/middleware/auth_token.go +++ b/internal/middleware/auth_token.go @@ -1,48 +1,3 @@ package middleware -import ( - "log" - - "metazone.cc/metalab/internal/common" - - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" -) - -// authenticateToken 统一的认证核心流程:读 Cookie → 解析 JWT → 检查 token_version -func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, error) { - tokenStr, err := c.Cookie(common.CookieName) - if err != nil { - return nil, err - } - claims, err := parseToken(tokenStr, am.cfg.JWT.Secret) - if err != nil { - return nil, err - } - uid := uint(claims["uid"].(float64)) - tokenVer := int(claims["ver"].(float64)) - currentVer, err := am.userRepo.FindTokenVersion(uid) - if err != nil { - return nil, err - } - if tokenVer != currentVer { - log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer) - return nil, common.ErrTokenRevoked - } - return claims, nil -} - -// injectUserContext 将 JWT claims 中的用户信息注入 gin context -func injectUserContext(c *gin.Context, claims jwt.MapClaims) { - if claims == nil { - return - } - uid, ok := claims["uid"].(float64) - if !ok { - return - } - c.Set("uid", uint(uid)) - c.Set("email", claims["email"]) - c.Set("username", claims["username"]) - c.Set("role", claims["role"]) -} +// auth_token.go: JWT 解析逻辑已被服务端 Session 替换,本文件保留占位,后续清理。 diff --git a/internal/middleware/csrf_token.go b/internal/middleware/csrf_token.go index f37795d..639fc15 100644 --- a/internal/middleware/csrf_token.go +++ b/internal/middleware/csrf_token.go @@ -26,7 +26,7 @@ func SetCSRFToken(c *gin.Context, cfg *config.Config) string { } c.SetSameSite(http.SameSiteStrictMode) - maxAge := int(cfg.JWT.RememberExpire * 3600) + maxAge := cfg.Session.RememberTimeout * 60 c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false) c.Set(csrfMetaName, token) return token diff --git a/internal/middleware/maintenance.go b/internal/middleware/maintenance.go index 82a59c5..77cd68b 100644 --- a/internal/middleware/maintenance.go +++ b/internal/middleware/maintenance.go @@ -7,19 +7,21 @@ import ( "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/session" "github.com/gin-gonic/gin" ) // MaintenanceMiddleware 维护模式中间件:维护期间仅站长(Owner)可访问 type MaintenanceMiddleware struct { - cfg *config.Config - siteSettings *config.SiteSettings + cfg *config.Config + siteSettings *config.SiteSettings + sessionManager *session.Manager } // NewMaintenanceMiddleware 构造函数 -func NewMaintenanceMiddleware(cfg *config.Config, siteSettings *config.SiteSettings) *MaintenanceMiddleware { - return &MaintenanceMiddleware{cfg: cfg, siteSettings: siteSettings} +func NewMaintenanceMiddleware(cfg *config.Config, siteSettings *config.SiteSettings, sm *session.Manager) *MaintenanceMiddleware { + return &MaintenanceMiddleware{cfg: cfg, siteSettings: siteSettings, sessionManager: sm} } // Handler 返回 Gin 中间件处理函数 @@ -39,51 +41,43 @@ func (mm *MaintenanceMiddleware) Handler() gin.HandlerFunc { return } - // 尝试读取 token 并解析角色 - tokenStr, err := c.Cookie(common.CookieName) - if err != nil { - // 未登录 → 重定向到登录页(API 返回 403) + // 读取 session cookie 并验证 + sid, err := c.Cookie(common.SessionCookieName) + if err != nil || sid == "" { blockAccess(c, path) return } - claims, err := parseToken(tokenStr, mm.cfg.JWT.Secret) - if err != nil { + s, err := mm.sessionManager.Validate(sid) + if err != nil || s == nil { blockAccess(c, path) return } - role, _ := claims["role"].(string) - if !model.HasMinRole(role, model.RoleOwner) { + if !model.HasMinRole(s.Role, model.RoleOwner) { blockAccess(c, path) return } - // 注入用户信息到 context(后续 auth 中间件可能还需要) - injectUserContext(c, claims) - c.Next() } } // isMaintenanceWhitelist 判断路径是否在维护白名单中 func isMaintenanceWhitelist(path string) bool { - // 登录页面和认证 API if path == "/auth/login" || strings.HasPrefix(path, "/api/auth/") { return true } - // 静态资源 if strings.HasPrefix(path, "/static/") || strings.HasPrefix(path, "/shared/") { return true } - // favicon if path == "/favicon.ico" || path == "/favicon.svg" { return true } return false } -// blockAccess 根据请求类型阻止访问(页面重定向,API 返回 403 JSON) +// blockAccess 根据请求类型阻止访问 func blockAccess(c *gin.Context, path string) { if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/admin/api/") { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ diff --git a/internal/middleware/repository.go b/internal/middleware/repository.go index 621a289..f28f1b5 100644 --- a/internal/middleware/repository.go +++ b/internal/middleware/repository.go @@ -1,6 +1,7 @@ package middleware -// tokenVersionStore 最小接口:仅暴露 AuthMiddleware 需要的 1 个方法 +// tokenVersionStore 最小接口:AuthMiddleware 不再需要此接口(session 替换 JWT) +// 保留占位,待后续清理 type tokenVersionStore interface { FindTokenVersion(userID uint) (int, error) } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index a786d1f..dc32615 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -1,12 +1,15 @@ package router import ( + "time" + "metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/controller" adminCtrl "metazone.cc/metalab/internal/controller/admin" "metazone.cc/metalab/internal/middleware" "metazone.cc/metalab/internal/repository" "metazone.cc/metalab/internal/service" + "metazone.cc/metalab/internal/session" "gorm.io/gorm" ) @@ -28,17 +31,26 @@ type dependencies struct { } func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) ( - *repository.UserRepo, *service.TokenService, *service.AuthService, + *repository.UserRepo, *session.Manager, *service.AuthService, *middleware.RateLimiter, *controller.AuthController, *service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController, ) { userRepo := repository.NewUserRepo(db) - tokenSvc := service.NewTokenService(cfg, userRepo) - authService := service.NewAuthService(userRepo, tokenSvc, cfg, siteSettings) + + // Session 存储:内存模式(后续可切换为 RedisStore) + sessionStore := session.NewMemoryStore( + time.Duration(cfg.Session.IdleTimeout)*time.Minute, + time.Duration(cfg.Session.RememberTimeout)*time.Minute, + ) + // 启动后台过清理 goroutine + sessionStore.StartCleanup(time.Duration(cfg.Session.CleanupInterval) * time.Second) + + sessionMgr := session.NewManager(sessionStore, userRepo, cfg) + authService := service.NewAuthService(userRepo, sessionMgr, cfg, siteSettings) rateLimiter := middleware.NewRateLimiter() - authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg) + authCtrl := controller.NewAuthController(authService, sessionMgr, rateLimiter, cfg) avatarSvc := service.NewAvatarService(userRepo) - authMdw := middleware.NewAuthMiddleware(cfg, userRepo) - adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo)) - return userRepo, tokenSvc, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController + authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr) + adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, sessionMgr)) + return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController } diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index f773890..acb4090 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -12,7 +12,7 @@ import ( ) func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) *dependencies { - userRepo, _, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings) + userRepo, sessionMgr, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings) auditRepo := repository.NewAuditRepo(db) notificationRepo := repository.NewNotificationRepo(db) @@ -42,7 +42,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting spaceService := service.NewSpaceService(userRepo, postRepo) spaceCtrl := controller.NewSpaceController(spaceService) - maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings) + maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr) return &dependencies{ authCtrl: authCtrl, authMdw: authMdw, maintenanceMdw: maintenanceMdw, diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index 46df3b1..7ab641a 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -3,14 +3,17 @@ package service import ( "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/session" ) + type AdminService struct { - userRepo userAdminStore + userRepo userAdminStore + sessionManager *session.Manager } // NewAdminService 构造函数 -func NewAdminService(userRepo userAdminStore) *AdminService { - return &AdminService{userRepo: userRepo} +func NewAdminService(userRepo userAdminStore, sm *session.Manager) *AdminService { + return &AdminService{userRepo: userRepo, sessionManager: sm} } // ListUsersParams 用户列表查询参数 @@ -92,14 +95,20 @@ func (s *AdminService) UpdateUserStatus(operatorUID, targetUID uint, newStatus s return err } } - // 修改状态必须立刻让 JWT 失效 + // 修改状态必须立刻让已有 session 失效 + 递增 token_version(向后兼容) + if err := s.sessionManager.DestroyByUID(target.ID); err != nil { + return err + } return s.userRepo.IncrementTokenVersion(target.ID) }) } -// ResetUserToken 强制下线(递增 token_version) +// ResetUserToken 强制下线:销毁所有 session + 递增 token_version func (s *AdminService) ResetUserToken(operatorUID, targetUID uint) error { return s.checkAndOperate(operatorUID, targetUID, func(_ *model.User, target *model.User) error { + if err := s.sessionManager.DestroyByUID(target.ID); err != nil { + return err + } return s.userRepo.IncrementTokenVersion(target.ID) }) } diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 0e8888d..a4c529b 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -8,6 +8,7 @@ import ( "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/session" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" @@ -15,15 +16,15 @@ import ( // AuthService 认证业务逻辑 type AuthService struct { - userRepo userAuthStore - tokenService tokenProvider - cfg *config.Config - siteSettings *config.SiteSettings + userRepo userAuthStore + sessionManager *session.Manager + cfg *config.Config + siteSettings *config.SiteSettings } // NewAuthService 构造函数 -func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.Config, siteSettings *config.SiteSettings) *AuthService { - return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg, siteSettings: siteSettings} +func NewAuthService(userRepo userAuthStore, sm *session.Manager, cfg *config.Config, siteSettings *config.SiteSettings) *AuthService { + return &AuthService{userRepo: userRepo, sessionManager: sm, cfg: cfg, siteSettings: siteSettings} } var pwLetter = regexp.MustCompile(`[a-zA-Z]`) @@ -33,7 +34,6 @@ var pwDigit = regexp.MustCompile(`\d`) var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`) // dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对 -// 在 init() 中按当前 bcrypt 成本生成,确保时序与真实校验一致 var dummyHash []byte func init() { @@ -58,46 +58,44 @@ func (s *AuthService) IsRegistrationEnabled() bool { } // Register 注册 -// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT -// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除) -// regIP: 注册 IP 地址 -func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) { +// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → 返回 user(session 由 controller 创建) +func (s *AuthService) Register(req model.RegisterRequest, regIP string) (*model.User, error) { // 0. 维护期间禁止注册 if s.siteSettings.IsMaintenanceEnabled() { - return "", "", nil, common.ErrMaintenanceMode + return nil, common.ErrMaintenanceMode } // 1. 检查注册开关 if !s.siteSettings.IsRegistrationEnabled() { - return "", "", nil, common.ErrRegistrationDisabled + return nil, common.ErrRegistrationDisabled } - // 1. 密码强度 + // 2. 密码强度 if err := validatePassword(req.Password); err != nil { - return "", "", nil, err + return nil, err } - // 2. 检查邮箱 + // 3. 检查邮箱 exists, err := s.userRepo.ExistsByEmail(req.Email) if err != nil { - return "", "", nil, err + return nil, err } if exists { - return "", "", nil, common.ErrEmailExists + return nil, common.ErrEmailExists } - // 3. 哈希密码 + // 4. 哈希密码 hash, err := common.HashPassword(req.Password, s.cfg.Bcrypt.Cost) if err != nil { - return "", "", nil, err + return nil, err } - // 4. 生成唯一用户名 + // 5. 生成唯一用户名 username, err := common.GenerateUsername(s.userRepo, 20) if err != nil { - return "", "", nil, err + return nil, err } - // 5. 创建用户 + // 6. 创建用户 user := &model.User{ Email: req.Email, PasswordHash: hash, @@ -107,70 +105,56 @@ func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string, RegIP: regIP, } if err := s.userRepo.Create(user); err != nil { - return "", "", nil, err + return nil, err } - // 6. 生成 access JWT - accessToken, err := s.tokenService.BuildAccessToken(user) - if err != nil { - return "", "", nil, err - } - - // 7. 生成 refresh JWT(不勾选"记住我"也用 session cookie,关浏览器即清除) - refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe) - if err != nil { - return "", "", nil, err - } - - return accessToken, refreshToken, user, nil + return user, nil } // Login 登录 -// 流程:按邮箱查找 → 维护期间非站长统一拦截(模拟 bcrypt 防时序)→ 状态检查 → 验证密码 → 签发 token -// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除) -func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) { +// 流程:按邮箱查找 → 维护期间非站长统一拦截 → 状态检查 → 验证密码 → 记录登录信息 → 返回 user +func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User, error) { user, err := s.userRepo.FindByEmail(req.Email) - // 维护期间:非站长一律返回统一提示(模拟 bcrypt 防时序攻击,不区分账号是否存在) + // 维护期间:非站长一律返回统一提示(模拟 bcrypt 防时序攻击) if s.siteSettings.IsMaintenanceEnabled() { if err != nil { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) - return "", "", nil, common.ErrMaintenanceMode + return nil, common.ErrMaintenanceMode } if !model.HasMinRole(user.Role, model.RoleOwner) { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) - return "", "", nil, common.ErrMaintenanceMode + return nil, common.ErrMaintenanceMode } - // 站长 → 继续正常登录流程 } if err != nil { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) - return "", "", nil, common.ErrInvalidCred + return nil, common.ErrInvalidCred } - // 已注销(deleted)→ 验证密码后要求二次确认,不自动恢复 + // 已注销(deleted)→ 验证密码后要求二次确认 if user.Status == model.StatusDeleted { if !common.CheckPassword(req.Password, user.PasswordHash) { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) - return "", "", nil, common.ErrInvalidCred + return nil, common.ErrInvalidCred } - return "", "", nil, common.ErrNeedsConfirmRestore + return nil, common.ErrNeedsConfirmRestore } // 永久锁定 if user.Status == model.StatusLocked { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) - return "", "", nil, common.ErrUserLocked + return nil, common.ErrUserLocked } // 封禁 if user.Status == model.StatusBanned { - return "", "", nil, common.ErrUserBanned + return nil, common.ErrUserBanned } if !common.CheckPassword(req.Password, user.PasswordHash) { - return "", "", nil, common.ErrInvalidCred + return nil, common.ErrInvalidCred } // 记录登录 IP 和时间 @@ -178,40 +162,26 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, str user.LastLoginIP = loginIP user.LastLoginAt = &now if err := s.userRepo.Update(user); err != nil { - return "", "", nil, err + return nil, err } - // 生成 access JWT - accessToken, err := s.tokenService.BuildAccessToken(user) - if err != nil { - return "", "", nil, err - } - - // 生成 refresh JWT(不勾选"记住我"也生成,Cookie 用 session 模式) - refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe) - if err != nil { - return "", "", nil, err - } - - return accessToken, refreshToken, user, nil + return user, nil } // ConfirmRestore 二次确认恢复已注销账号 -// 流程:按邮箱查找 → 验证密码 → 恢复为 active → 记录登录 IP/时间 → 签发 token -func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) { +func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) { user, err := s.userRepo.FindByEmail(req.Email) if err != nil { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) - return "", "", nil, common.ErrInvalidCred + return nil, common.ErrInvalidCred } - // 仅 deleted 状态允许恢复 if user.Status != model.StatusDeleted { - return "", "", nil, common.ErrUserNotFound + return nil, common.ErrUserNotFound } if !common.CheckPassword(req.Password, user.PasswordHash) { - return "", "", nil, common.ErrInvalidCred + return nil, common.ErrInvalidCred } // 恢复账号 @@ -221,51 +191,38 @@ func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (st user.LastLoginIP = loginIP user.LastLoginAt = &now if err := s.userRepo.Update(user); err != nil { - return "", "", nil, err + return nil, err } - accessToken, err := s.tokenService.BuildAccessToken(user) - if err != nil { - return "", "", nil, err - } - - refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe) - if err != nil { - return "", "", nil, err - } - - return accessToken, refreshToken, user, nil + return user, nil } -// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查) +// CheckEmail 检查邮箱是否已被注册 func (s *AuthService) CheckEmail(email string) (bool, error) { return s.userRepo.ExistsByEmail(email) } -// GetProfile 获取当前用户资料(供 SettingsController 使用) +// GetProfile 获取当前用户资料 func (s *AuthService) GetProfile(userID uint) (*model.User, error) { return s.userRepo.FindByID(userID) } // ChangePassword 修改密码 -// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 吊销所有 JWT(强制重新登录) +// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录) func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error { user, err := s.userRepo.FindByID(userID) if err != nil { return common.ErrUserNotFound } - // 验证当前密码 if !common.CheckPassword(currentPassword, user.PasswordHash) { return common.ErrIncorrectPassword } - // 新密码强度校验 if err := validatePassword(newPassword); err != nil { return err } - // 哈希新密码 hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost) if err != nil { return err @@ -276,24 +233,21 @@ func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword s return err } - // 强制所有设备重新登录(安全性) - return s.InvalidateSessions(userID) + // 删除所有 session,强制所有设备重新登录 + return s.invalidateSessions(userID) } // DeleteAccount 用户自主注销 -// 流程:检查角色 → 验证密码 → 记录原因 → 设置 deleted 状态 → 吊销所有 JWT → 7 天冷却期内登录需二次确认恢复 func (s *AuthService) DeleteAccount(userID uint, password, reason string) error { user, err := s.userRepo.FindByID(userID) if err != nil { return common.ErrUserNotFound } - // 站长不允许自主注销(避免权限体系死锁) if user.Role == model.RoleOwner { return common.ErrOwnerCannotDelete } - // 验证当前密码(防止 CSRF 或未授权操作) if !common.CheckPassword(password, user.PasswordHash) { return common.ErrIncorrectPassword } @@ -305,22 +259,28 @@ func (s *AuthService) DeleteAccount(userID uint, password, reason string) error return err } - // 递增 token_version,即时吊销所有 JWT 强制退登 - return s.InvalidateSessions(userID) -} - -// InvalidateSessions 吊销某用户所有 JWT(递增 token_version,强制所有设备重新登录) -// 适用场景:修改密码、账号被盗、管理员强制下线 -func (s *AuthService) InvalidateSessions(userID uint) error { + // 删除所有 session + 递增 token_version(向后兼容) + if err := s.invalidateSessions(userID); err != nil { + return err + } return s.userRepo.IncrementTokenVersion(userID) } -// UpdateProfile 修改个人资料(用户名 + 个性签名) -// 用户名校验:非空、1-16 字符、白名单、去重 -// 个性签名校验:0-128 字符纯文本,Go 模板自动 HTML 转义防 XSS -// 任一字段无变更时跳过该字段的写库操作 +// invalidateSessions 销毁某用户的所有 session(内部方法) +func (s *AuthService) invalidateSessions(userID uint) error { + return s.sessionManager.DestroyByUID(userID) +} + +// InvalidateSessions 公开方法:销毁用户所有 session(供 admin 等外部调用) +func (s *AuthService) InvalidateSessions(userID uint) error { + if err := s.invalidateSessions(userID); err != nil { + return err + } + return s.userRepo.IncrementTokenVersion(userID) +} + +// UpdateProfile 修改个人资料 func (s *AuthService) UpdateProfile(userID uint, username, bio string) error { - // 查当前用户 user, err := s.userRepo.FindByID(userID) if err != nil { return common.ErrUserNotFound @@ -328,7 +288,6 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error { needsUpdate := false - // 用户名:校验 + 更新 if n := utf8.RuneCountInString(username); n == 0 { return common.ErrUsernameInvalid } else if n > 16 { @@ -349,7 +308,6 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error { needsUpdate = true } - // 个性签名:长度校验 + 更新 if utf8.RuneCountInString(bio) > 128 { return common.ErrBioTooLong } diff --git a/internal/service/provider.go b/internal/service/provider.go index 278202e..8946c3b 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -1,9 +1,4 @@ package service -import "metazone.cc/metalab/internal/model" - -// tokenProvider AuthService 对 TokenService 的最小依赖(ISP:2 个方法) -type tokenProvider interface { - BuildAccessToken(user *model.User) (string, error) - BuildRefreshToken(user *model.User, rememberMe bool) (string, error) -} +// provider.go: tokenProvider 接口已移除,JWT 被服务端 Session 替换。 +// 本文件保留占位,后续清理。 diff --git a/internal/service/repository.go b/internal/service/repository.go index 6854966..566a6d6 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -2,8 +2,7 @@ package service import "metazone.cc/metalab/internal/model" -// userAuthStore AuthService 所需的最小仓储接口(ISP:7 个方法) -// 同时满足 common.UsernameChecker(ExistsByUsername),可传入 GenerateUsername +// userAuthStore AuthService 所需的最小仓储接口(ISP:8 个方法) type userAuthStore interface { ExistsByEmail(email string) (bool, error) Create(user *model.User) error @@ -14,12 +13,12 @@ type userAuthStore interface { ExistsByUsername(username string) (bool, error) } -// userTokenStore TokenService 所需的最小仓储接口(ISP:1 个方法) +// userTokenStore 已废弃(JWT 替换为 Session),保留占位 type userTokenStore interface { FindByIDForAuth(userID uint) (*model.User, error) } -// userAdminStore AdminService 所需的最小仓储接口(ISP:8 个方法) +// userAdminStore AdminService 所需的最小仓储接口 type userAdminStore interface { CountSearchUsers(keyword, role, status string) (int64, error) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) @@ -31,7 +30,7 @@ type userAdminStore interface { IncrementTokenVersion(userID uint) error } -// postStore PostService 所需的最小仓储接口(ISP:10 个方法) +// postStore PostService 所需的最小仓储接口 type postStore interface { Create(post *model.Post) error FindByID(id uint) (*model.Post, error) @@ -45,18 +44,18 @@ type postStore interface { Restore(id uint) error } -// spaceUserStore SpaceService 所需的最小用户仓储接口(ISP:1 个方法) +// spaceUserStore SpaceService 所需的最小用户仓储接口 type spaceUserStore interface { FindByID(id uint) (*model.User, error) } -// spacePostStore SpaceService 所需的最小帖子仓储接口(ISP:1 个方法) +// spacePostStore SpaceService 所需的最小帖子仓储接口 type spacePostStore interface { FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) } -// postNotifier PostService 所需的通知服务接口(ISP:2 个方法) +// postNotifier PostService 所需的通知服务接口 type postNotifier interface { NotifyPostApproved(userID uint, postTitle string, postID uint) NotifyPostRejected(userID uint, postTitle, reason string, postID uint) -} \ No newline at end of file +} diff --git a/internal/service/token_service.go b/internal/service/token_service.go index 37147c4..47c2e8b 100644 --- a/internal/service/token_service.go +++ b/internal/service/token_service.go @@ -1,122 +1,3 @@ package service -import ( - "log" - "time" - - "metazone.cc/metalab/internal/common" - "metazone.cc/metalab/internal/config" - "metazone.cc/metalab/internal/model" - - "github.com/golang-jwt/jwt/v5" -) - -// TokenService JWT 令牌签发与刷新 -// 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用 -type TokenService struct { - cfg *config.Config - userRepo userTokenStore -} - -// NewTokenService 构造函数 -func NewTokenService(cfg *config.Config, userRepo userTokenStore) *TokenService { - return &TokenService{cfg: cfg, userRepo: userRepo} -} - -// BuildAccessToken 构建 access JWT(含 uid/email/username/role/ver/exp/iat) -// ver = user.TokenVersion,用于即时吊销:版本号不匹配则拒绝 -func (ts *TokenService) BuildAccessToken(user *model.User) (string, error) { - expire := time.Duration(ts.cfg.JWT.AccessExpire) * time.Minute - now := time.Now() - expAt := now.Add(expire) - log.Printf("[BuildAccessToken] AccessExpire=%d min → expire=%v | now=%v | exp=%v (%d) | iat=%d ver=%d", - ts.cfg.JWT.AccessExpire, expire, now, expAt, expAt.Unix(), now.Unix(), user.TokenVersion) - claims := jwt.MapClaims{ - "uid": user.ID, - "email": user.Email, - "username": user.Username, - "role": user.Role, - "ver": user.TokenVersion, - "exp": expAt.Unix(), - "iat": now.Unix(), - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString([]byte(ts.cfg.JWT.Secret)) -} - -// BuildRefreshToken 构建 refresh JWT(含 purpose:"refresh" 防止 access 冒充) -// rememberMe=true → 使用 remember_expire(30天);false → 使用 refresh_expire(7天,session 模式) -// ver = user.TokenVersion,刷新时校验:版本号不匹配则拒绝 -func (ts *TokenService) BuildRefreshToken(user *model.User, rememberMe bool) (string, error) { - var expireHours int - if rememberMe { - expireHours = ts.cfg.JWT.RememberExpire - } else { - expireHours = ts.cfg.JWT.RefreshExpire - } - expire := time.Duration(expireHours) * time.Hour - now := time.Now() - claims := jwt.MapClaims{ - "uid": user.ID, - "email": user.Email, - "username": user.Username, - "role": user.Role, - "ver": user.TokenVersion, - "exp": now.Add(expire).Unix(), - "iat": now.Unix(), - "purpose": "refresh", - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString([]byte(ts.cfg.JWT.Secret)) -} - -// RefreshAccessToken 用 refresh token 换取新的 access token -// 校验 token_version:若 DB 中版本已递增,拒绝刷新 → 即时吊销 -func (ts *TokenService) RefreshAccessToken(refreshTokenStr string) (string, *model.User, error) { - if refreshTokenStr == "" { - return "", nil, common.ErrTokenInvalid - } - - token, err := jwt.Parse(refreshTokenStr, func(t *jwt.Token) (interface{}, error) { - return []byte(ts.cfg.JWT.Secret), nil - }) - if err != nil || !token.Valid { - return "", nil, common.ErrTokenExpired - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return "", nil, common.ErrTokenInvalid - } - - // 只接受 refresh 用途的 token,防止 access token 被用于刷新 - if purpose, _ := claims["purpose"].(string); purpose != "refresh" { - return "", nil, common.ErrTokenInvalid - } - - uid := uint(claims["uid"].(float64)) - tokenVer := int(claims["ver"].(float64)) - - // 即时吊销检查 + 获取最新用户数据(角色/状态可能在 JWT 签发后已变更) - // 用 FindByIDForAuth 而非从 claims 重建,确保 access token 承载最新数据 - user, err := ts.userRepo.FindByIDForAuth(uid) - if err != nil { - return "", nil, common.ErrTokenRevoked - } - - if tokenVer != user.TokenVersion { - log.Printf("[RefreshAccessToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, user.TokenVersion) - return "", nil, common.ErrTokenRevoked - } - - // 防止封禁用户通过 refresh 续期 - if user.Status == model.StatusBanned { - return "", nil, common.ErrUserBanned - } - - accessToken, err := ts.BuildAccessToken(user) - if err != nil { - return "", nil, err - } - return accessToken, user, nil -} +// token_service.go: JWT 签发与刷新逻辑已被服务端 Session 替换,本文件保留占位,后续清理。 diff --git a/internal/session/manager.go b/internal/session/manager.go new file mode 100644 index 0000000..9fdf877 --- /dev/null +++ b/internal/session/manager.go @@ -0,0 +1,104 @@ +package session + +import ( + "log" + "time" + + "metazone.cc/metalab/internal/config" + "metazone.cc/metalab/internal/model" +) + +// userStore SessionManager 对 UserRepo 的最小接口(ISP:1 个方法) +type userStore interface { + FindByIDForAuth(userID uint) (*model.User, error) +} + +// Manager 会话管理器:创建、验证、销毁、续期 +type Manager struct { + store Store + userRepo userStore + cfg *config.Config +} + +// NewManager 构造函数 +func NewManager(store Store, userRepo userStore, cfg *config.Config) *Manager { + return &Manager{store: store, userRepo: userRepo, cfg: cfg} +} + +// Create 创建会话并写入存储 +func (m *Manager) Create(user *model.User, rememberMe bool) (string, error) { + sid, err := NewID() + if err != nil { + return "", err + } + now := time.Now() + s := &Session{ + ID: sid, + UserID: user.ID, + Email: user.Email, + Username: user.Username, + Role: user.Role, + RememberMe: rememberMe, + CreatedAt: now, + LastAccess: now, + } + if err := m.store.Set(s); err != nil { + return "", err + } + log.Printf("[SessionManager] Created sid=%s uid=%d rememberMe=%v", sid[:16]+"...", user.ID, rememberMe) + return sid, nil +} + +// Validate 验证会话:读取 → 检查过期 → 检查用户状态 → 续期 → 返回 +// 返回 nil 表示会话无效(不存在/过期/用户被封禁) +func (m *Manager) Validate(sid string) (*Session, error) { + if sid == "" { + return nil, nil + } + + s, err := m.store.Get(sid) + if err != nil || s == nil { + return nil, nil + } + + // 检查用户是否仍然有效(封禁/注销后拒绝已有会话) + user, err := m.userRepo.FindByIDForAuth(s.UserID) + if err != nil { + log.Printf("[SessionManager] Validate: user not found uid=%d err=%v", s.UserID, err) + m.store.Delete(sid) + return nil, nil + } + + // 封禁用户直接拒绝 + if user.Status == model.StatusBanned { + log.Printf("[SessionManager] Validate: user banned uid=%d", s.UserID) + m.store.Delete(sid) + return nil, nil + } + + // 滑动窗口续期 + s.Touch() + if err := m.store.Set(s); err != nil { + log.Printf("[SessionManager] Validate: touch failed sid=%s err=%v", sid[:16]+"...", err) + } + + return s, nil +} + +// Destroy 销毁单个会话(用户主动退出登录) +func (m *Manager) Destroy(sid string) error { + return m.store.Delete(sid) +} + +// DestroyByUID 销毁某用户的所有会话(改密/注销/强制下线) +func (m *Manager) DestroyByUID(uid uint) error { + return m.store.DeleteByUID(uid) +} + +// IdleTimeout 根据 rememberMe 返回对应的空闲超时时间 +func (m *Manager) IdleTimeout(rememberMe bool) time.Duration { + if rememberMe { + return time.Duration(m.cfg.Session.RememberTimeout) * time.Minute + } + return time.Duration(m.cfg.Session.IdleTimeout) * time.Minute +} diff --git a/internal/session/memory_store.go b/internal/session/memory_store.go new file mode 100644 index 0000000..1a2eb48 --- /dev/null +++ b/internal/session/memory_store.go @@ -0,0 +1,143 @@ +package session + +import ( + "log" + "sync" + "time" +) + +// MemoryStore 基于内存的会话存储(sync.Map + RWMutex,适合单实例部署) +type MemoryStore struct { + mu sync.RWMutex + sessions map[string]*Session // sid → session + byUID map[uint][]string // uid → []sid(用于批量删除) + idleTimeout time.Duration // 不记住我:空闲超时 + rememberTimeout time.Duration // 记住我:空闲超时 +} + +// NewMemoryStore 创建内存存储实例 +func NewMemoryStore(idleTimeout, rememberTimeout time.Duration) *MemoryStore { + return &MemoryStore{ + sessions: make(map[string]*Session), + byUID: make(map[uint][]string), + idleTimeout: idleTimeout, + rememberTimeout: rememberTimeout, + } +} + +// Get 获取会话(根据 RememberMe 选择对应的空闲超时检查过期) +func (ms *MemoryStore) Get(id string) (*Session, error) { + ms.mu.RLock() + s, ok := ms.sessions[id] + ms.mu.RUnlock() + if !ok { + return nil, nil + } + // 记住我 → 使用更长的超时 + timeout := ms.idleTimeout + if s.RememberMe { + timeout = ms.rememberTimeout + } + if s.IsExpired(timeout) { + ms.Delete(id) + return nil, nil + } + return s, nil +} + +// Set 写入会话(同步更新 byUID 索引) +func (ms *MemoryStore) Set(s *Session) error { + ms.mu.Lock() + defer ms.mu.Unlock() + + // 如果 sid 已存在且 uid 不同,先从旧 uid 索引移除 + if old, ok := ms.sessions[s.ID]; ok && old.UserID != s.UserID { + ms.removeFromUIDIndex(old.UserID, s.ID) + } + + ms.sessions[s.ID] = s + ms.byUID[s.UserID] = append(ms.byUID[s.UserID], s.ID) + return nil +} + +// Delete 删除会话(同步更新 byUID 索引) +func (ms *MemoryStore) Delete(id string) error { + ms.mu.Lock() + defer ms.mu.Unlock() + + s, ok := ms.sessions[id] + if !ok { + return nil + } + ms.removeFromUIDIndex(s.UserID, id) + delete(ms.sessions, id) + return nil +} + +// DeleteByUID 删除某用户的所有会话 +func (ms *MemoryStore) DeleteByUID(uid uint) error { + ms.mu.Lock() + defer ms.mu.Unlock() + + sids, ok := ms.byUID[uid] + if !ok { + return nil + } + for _, sid := range sids { + delete(ms.sessions, sid) + } + delete(ms.byUID, uid) + log.Printf("[MemoryStore] DeleteByUID uid=%d count=%d", uid, len(sids)) + return nil +} + +// Cleanup 清理过期会话(根据 RememberMe 选择对应超时) +func (ms *MemoryStore) Cleanup() int { + ms.mu.Lock() + defer ms.mu.Unlock() + + var expiredSids []string + for sid, s := range ms.sessions { + timeout := ms.idleTimeout + if s.RememberMe { + timeout = ms.rememberTimeout + } + if s.IsExpired(timeout) { + expiredSids = append(expiredSids, sid) + } + } + for _, sid := range expiredSids { + s := ms.sessions[sid] + ms.removeFromUIDIndex(s.UserID, sid) + delete(ms.sessions, sid) + } + if len(expiredSids) > 0 { + log.Printf("[MemoryStore] Cleanup removed=%d remaining=%d", len(expiredSids), len(ms.sessions)) + } + return len(expiredSids) +} + +// removeFromUIDIndex 从 byUID 索引中移除指定 sid(调用者需持有写锁) +func (ms *MemoryStore) removeFromUIDIndex(uid uint, sid string) { + sids := ms.byUID[uid] + for i, s := range sids { + if s == sid { + ms.byUID[uid] = append(sids[:i], sids[i+1:]...) + break + } + } + if len(ms.byUID[uid]) == 0 { + delete(ms.byUID, uid) + } +} + +// StartCleanup 启动后台清理 goroutine(应在 main 中调用) +func (ms *MemoryStore) StartCleanup(interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + ms.Cleanup() + } + }() +} diff --git a/internal/session/redis_store.go b/internal/session/redis_store.go new file mode 100644 index 0000000..bb4aa9f --- /dev/null +++ b/internal/session/redis_store.go @@ -0,0 +1,29 @@ +package session + +// RedisStore Redis 会话存储(预留实现,切换到 Redis 时启用) +// import "github.com/redis/go-redis/v9" +// +// type RedisStore struct { +// client *redis.Client +// ttl time.Duration +// } +// +// func NewRedisStore(client *redis.Client, ttl time.Duration) *RedisStore { +// return &RedisStore{client: client, ttl: ttl} +// } +// +// func (rs *RedisStore) Get(id string) (*Session, error) { ... } +// func (rs *RedisStore) Set(s *Session) error { ... } +// func (rs *RedisStore) Delete(id string) error { ... } +// func (rs *RedisStore) DeleteByUID(uid uint) error { ... } +// func (rs *RedisStore) Cleanup() int { return 0 } +// +// Redis key pattern: +// session:{sid} → serialized Session (TTL = idle timeout) +// user_sessions:{uid} → Set of sids (for batch deletion) + +// 当前使用内存存储,Redis 支持预留接口,未来需切换时: +// 1. go get github.com/redis/go-redis/v9 +// 2. 取消本文件注释并实现 RedisStore +// 3. 在 deps_core.go 中将 NewMemoryStore → NewRedisStore +// 4. 注入 redis client(从 config 读取 Redis 连接信息) diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..f7ce729 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,38 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" + "time" +) + +// Session 服务端会话,替代 JWT 无状态令牌 +type Session struct { + ID string // 会话唯一标识(64 位 hex) + UserID uint // 用户 ID + Email string // 邮箱 + Username string // 用户名 + Role string // 角色 + RememberMe bool // 是否持久化(决定 cookie maxAge) + CreatedAt time.Time // 创建时间 + LastAccess time.Time // 最后访问时间(滑动窗口) +} + +// IsExpired 检查会话是否已过期 +func (s *Session) IsExpired(idleTimeout time.Duration) bool { + return time.Since(s.LastAccess) > idleTimeout +} + +// Touch 更新最后访问时间(续期) +func (s *Session) Touch() { + s.LastAccess = time.Now() +} + +// NewID 生成 32 字节随机 hex 字符串作为会话 ID +func NewID() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/session/store.go b/internal/session/store.go new file mode 100644 index 0000000..8b267c7 --- /dev/null +++ b/internal/session/store.go @@ -0,0 +1,19 @@ +package session + +// Store 会话存储接口(内存实现 / Redis 实现均实现此接口) +type Store interface { + // Get 按会话 ID 获取会话,不存在或已过期返回 nil + Get(id string) (*Session, error) + + // Set 写入/更新会话 + Set(s *Session) error + + // Delete 删除单个会话 + Delete(id string) error + + // DeleteByUID 删除某用户的所有会话(改密/注销/强制下线时调用) + DeleteByUID(uid uint) error + + // Cleanup 清理过期会话,返回清理数量(由后台 goroutine 定期调用) + Cleanup() int +}