|
|
|
|
|
//
|
|
|
|
|
|
// Copyright (C) 2024 veypi <i@veypi.com>
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|