// // Copyright (C) 2024 veypi // 2025-03-04 16:08:06 // Distributed under terms of the MIT license. // package auth import ( "net/http" "time" "github.com/veypi/vbase/auth" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/libs/crypto" "github.com/veypi/vbase/libs/jwt" "github.com/veypi/vbase/models" "github.com/veypi/vigo" ) // LoginRequest 登录请求 type LoginRequest struct { Username string `json:"username" src:"json" desc:"用户名/邮箱/手机号"` Password string `json:"password" src:"json" desc:"密码"` CaptchaID string `json:"captcha_id,omitempty" src:"json" desc:"验证码ID"` CaptchaCode string `json:"captcha_code,omitempty" src:"json" desc:"验证码"` Remember bool `json:"remember,omitempty" src:"json" desc:"记住登录"` } // AuthResponse 认证响应(token 仅通过 HttpOnly Cookie 下发,body 只含用户信息) type AuthResponse struct { AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` TokenType string `json:"token_type,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` User *UserInfo `json:"user"` } // UserInfo 用户信息 type UserInfo struct { ID string `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Email *string `json:"email"` Avatar string `json:"avatar"` } // LoginWithCodeRequest 验证码登录请求 type LoginWithCodeRequest struct { Type string `json:"type" src:"json" desc:"类型: email/sms"` Target string `json:"target" src:"json" desc:"邮箱或手机号"` Code string `json:"code" src:"json" desc:"验证码"` } // loginWithCode 验证码登录 func loginWithCode(x *vigo.X, req *LoginWithCodeRequest) (*AuthResponse, error) { // 参数校验 if req.Type != "email" && req.Type != "sms" { return nil, vigo.ErrInvalidArg.WithString("invalid type, must be email or sms") } if req.Target == "" || req.Code == "" { return nil, vigo.ErrInvalidArg.WithString("target and code are required") } db := cfg.DB() // 查找最新验证码 var verification models.VerificationCode if err := db.Where("target = ? AND type = ? AND purpose = ?", req.Target, req.Type, models.CodePurposeLogin). Order("created_at DESC").First(&verification).Error; err != nil { return nil, vigo.ErrInvalidArg.WithString("invalid verification code") } // 检查验证码状态 maxAttempts, _ := models.GetSettingInt(models.SettingCodeMaxAttempt) if maxAttempts == 0 { maxAttempts = 3 } if !verification.CanRetry(maxAttempts) { return nil, vigo.ErrInvalidArg.WithString("verification code expired or max attempts exceeded") } // 验证code if verification.Code != req.Code { // 增加尝试次数 db.Model(&verification).UpdateColumn("attempts", verification.Attempts+1) return nil, vigo.ErrInvalidArg.WithString("invalid verification code") } // 标记为已使用 now := time.Now() verification.Status = models.CodeStatusUsed verification.UsedAt = &now db.Save(&verification) // 查找用户 var user models.User var queryErr error if req.Type == "email" { queryErr = db.Where("email = ?", req.Target).First(&user).Error } else { queryErr = db.Where("phone = ?", req.Target).First(&user).Error } if queryErr != nil { return nil, vigo.ErrUnauthorized.WithString("user not found") } // 检查用户状态 if user.Status != models.UserStatusActive { return nil, vigo.ErrForbidden.WithString("user is disabled") } // 生成token return generateAuthResponseForUser(x, &user) } // generateAuthResponseForUser 为用户生成认证响应(登录时递增版本,踢掉旧会话) func generateAuthResponseForUser(x *vigo.X, user *models.User) (*AuthResponse, error) { version, err := auth.IncrTokenVersion(user.ID) if err != nil { return nil, vigo.ErrInternalServer.WithError(err) } tokenPair, err := jwt.GenerateTokenPair(user.ID, version) if err != nil { return nil, vigo.ErrInternalServer.WithError(err) } // 设置 HttpOnly Cookie(浏览器自动携带),body 仅返回用户信息 setTokenCookies(x, tokenPair) return &AuthResponse{ User: &UserInfo{ ID: user.ID, Username: user.Username, Nickname: user.Nickname, Email: user.Email, Avatar: user.Avatar, }, }, nil } // login 用户登录 func login(x *vigo.X, req *LoginRequest) (*AuthResponse, error) { // 查找用户 var user models.User query := cfg.DB().Where("username = ? OR email = ? OR phone = ?", req.Username, req.Username, req.Username) if err := query.First(&user).Error; err != nil { return nil, vigo.ErrUnauthorized.WithString("invalid username or password") } // 检查用户状态 if user.Status != models.UserStatusActive { return nil, vigo.ErrForbidden.WithString("user is disabled") } // 验证密码 if !crypto.VerifyPassword(req.Password, user.Password) { return nil, vigo.ErrUnauthorized.WithString("invalid username or password") } // 更新最后登录时间 now := time.Now() cfg.DB().Model(&user).Update("last_login_at", now) // 生成 token + Set-Cookie return generateAuthResponseForUser(x, &user) } // RefreshRequest 刷新请求 type RefreshRequest struct { RefreshToken string `json:"refresh_token" src:"json" desc:"刷新令牌"` } // refresh 刷新Token(Token Rotation:严格验证版本并轮换) func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) { refreshToken := req.RefreshToken // Body 为空时从 Cookie 读取 if refreshToken == "" { if c, err := x.Request.Cookie(refreshCookieName()); err == nil { refreshToken = c.Value } } if refreshToken == "" { return nil, vigo.ErrTokenInvalid } claims, err := jwt.ParseToken(refreshToken) if err != nil { if err == jwt.ErrExpiredToken { return nil, vigo.ErrTokenExpired } return nil, vigo.ErrTokenInvalid } if !jwt.IsRefreshToken(claims) { return nil, vigo.ErrTokenInvalid } // 查找用户 var user models.User if err := cfg.DB().First(&user, "id = ?", claims.UserID).Error; err != nil { return nil, vigo.ErrTokenInvalid } if user.Status != models.UserStatusActive { return nil, vigo.ErrForbidden.WithString("user is disabled") } // 严格验证 refresh_token 版本并递增(版本不匹配=已泄露/已用) newVersion, err := auth.ValidateRefreshAndRotate(claims.UserID, claims.Version) if err != nil { return nil, vigo.ErrTokenInvalid.WithString("refresh token is stale or already used") } // 生成新 token pair(新版本号) tokenPair, err := jwt.GenerateTokenPair(user.ID, newVersion) if err != nil { return nil, vigo.ErrInternalServer.WithError(err) } // 更新 Cookie,body 仅返回用户信息 setTokenCookies(x, tokenPair) return &AuthResponse{ User: &UserInfo{ ID: user.ID, Username: user.Username, Nickname: user.Nickname, Email: user.Email, Avatar: user.Avatar, }, }, nil } // logout 用户登出(版本递增,全部 token 失效) func logout(x *vigo.X) error { userID := cfg.Auth.UserID(x) if userID == "" { return nil } // 递增 token 版本 → 已签发的所有 token 全部失效 if err := auth.RevokeAllTokens(userID); err != nil { return vigo.ErrInternalServer.WithError(err) } // 清除 Cookie clearTokenCookies(x) return nil } // ========== Cookie 辅助函数 ========== func accessCookieName() string { return cfg.Global.JWT.CookiePrefix + "access" } func refreshCookieName() string { return cfg.Global.JWT.CookiePrefix + "refresh" } // setTokenCookies 设置 HttpOnly Cookie func setTokenCookies(x *vigo.X, tokenPair *jwt.TokenPair) { refreshTokenPath := Router.String() + "/refresh" cp := cfg.Global.JWT.CookiePath http.SetCookie(x.ResponseWriter(), &http.Cookie{ Name: accessCookieName(), Value: tokenPair.AccessToken, Path: cp, MaxAge: int(cfg.Global.JWT.AccessExpiry.Seconds()), HttpOnly: true, Secure: x.Request.TLS != nil, SameSite: http.SameSiteStrictMode, }) http.SetCookie(x.ResponseWriter(), &http.Cookie{ Name: refreshCookieName(), Value: tokenPair.RefreshToken, Path: refreshTokenPath, MaxAge: int(cfg.Global.JWT.RefreshExpiry.Seconds()), HttpOnly: true, Secure: x.Request.TLS != nil, SameSite: http.SameSiteStrictMode, }) } // clearTokenCookies 清除 Cookie(登出时调用) func clearTokenCookies(x *vigo.X) { cp := cfg.Global.JWT.CookiePath refreshTokenPath := Router.String() + "/refresh" http.SetCookie(x.ResponseWriter(), &http.Cookie{ Name: accessCookieName(), Value: "", Path: cp, MaxAge: -1, HttpOnly: true, }) http.SetCookie(x.ResponseWriter(), &http.Cookie{ Name: refreshCookieName(), Value: "", Path: refreshTokenPath, MaxAge: -1, HttpOnly: true, }) }