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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//
// 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/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")
}
// 生成新 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,
})
}