You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
OneAuth/api/auth/login.go

304 lines
8.5 KiB
Go

//
// 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 {
4 months ago
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/phone"`
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 != "phone" {
return nil, vigo.ErrInvalidArg.WithString("invalid type, must be email or phone")
}
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.GetTokenVersion(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 刷新TokenToken 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")
4 months ago
}
// 生成新 token pair新版本号
tokenPair, err := jwt.GenerateTokenPair(user.ID, newVersion)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
// 更新 Cookiebody 仅返回用户信息
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 辅助函数 ==========
const (
accessCookieName = "vb_access"
refreshCookieName = "vb_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,
})
}