|
|
|
|
|
//
|
|
|
|
|
|
// Copyright (C) 2024 veypi <i@veypi.com>
|
|
|
|
|
|
// 2025-03-04 16:08:06
|
|
|
|
|
|
// Distributed under terms of the MIT license.
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
package auth
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"net"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"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 为用户生成认证响应(创建新 session,支持多点登录)
|
|
|
|
|
|
func generateAuthResponseForUser(x *vigo.X, user *models.User) (*AuthResponse, error) {
|
|
|
|
|
|
deviceInfo, ip := extractDeviceInfo(x.Request)
|
|
|
|
|
|
expiresAt := time.Now().Add(cfg.Global.JWT.RefreshExpiry)
|
|
|
|
|
|
session, err := auth.CreateSession(user.ID, deviceInfo, ip, expiresAt)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, vigo.ErrInternalServer.WithError(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tokenPair, err := jwt.GenerateTokenPair(user.ID, session.ID, session.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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Session 级别版本旋转(版本不匹配=已泄露/已用)
|
|
|
|
|
|
newVersion, err := auth.ValidateRefreshSession(claims.UserID, claims.SessionID, claims.Version)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, vigo.ErrTokenInvalid.WithString("refresh token is stale or already used")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 生成新 token pair(同一 session,新版本号)
|
|
|
|
|
|
tokenPair, err := jwt.GenerateTokenPair(user.ID, claims.SessionID, 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 用户登出(仅撤销当前会话)
|
|
|
|
|
|
func logout(x *vigo.X) error {
|
|
|
|
|
|
userID := cfg.Auth.UserID(x)
|
|
|
|
|
|
sessionID := auth.GetCurrentSessionID(x)
|
|
|
|
|
|
if userID == "" || sessionID == "" {
|
|
|
|
|
|
clearTokenCookies(x)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := auth.RevokeSession(userID, sessionID); err != nil {
|
|
|
|
|
|
return vigo.ErrInternalServer.WithError(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========== 设备信息提取 ==========
|
|
|
|
|
|
|
|
|
|
|
|
// extractDeviceInfo 从请求中提取设备信息和 IP
|
|
|
|
|
|
func extractDeviceInfo(r *http.Request) (deviceInfo, ip string) {
|
|
|
|
|
|
// IP
|
|
|
|
|
|
ip = r.Header.Get("X-Forwarded-For")
|
|
|
|
|
|
if ip == "" {
|
|
|
|
|
|
ip = r.Header.Get("X-Real-IP")
|
|
|
|
|
|
}
|
|
|
|
|
|
if ip == "" {
|
|
|
|
|
|
ip = r.RemoteAddr
|
|
|
|
|
|
}
|
|
|
|
|
|
// 取第一个 IP(X-Forwarded-For 可能含多个)
|
|
|
|
|
|
if idx := strings.IndexByte(ip, ','); idx != -1 {
|
|
|
|
|
|
ip = strings.TrimSpace(ip[:idx])
|
|
|
|
|
|
}
|
|
|
|
|
|
// 去掉端口号(RemoteAddr 格式为 IP:port)
|
|
|
|
|
|
if host, _, err := net.SplitHostPort(ip); err == nil {
|
|
|
|
|
|
ip = host
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// User-Agent(截断到 300 字符)
|
|
|
|
|
|
ua := r.UserAgent()
|
|
|
|
|
|
if len(ua) > 300 {
|
|
|
|
|
|
ua = ua[:300]
|
|
|
|
|
|
}
|
|
|
|
|
|
deviceInfo = ua
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========== 会话管理 API ==========
|
|
|
|
|
|
|
|
|
|
|
|
// SessionInfo 会话信息
|
|
|
|
|
|
type SessionInfo struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
DeviceInfo string `json:"device_info"`
|
|
|
|
|
|
IP string `json:"ip"`
|
|
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
|
|
|
|
IsCurrent bool `json:"is_current"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// listSessions 获取当前用户所有活跃会话
|
|
|
|
|
|
func listSessions(x *vigo.X) ([]SessionInfo, error) {
|
|
|
|
|
|
userID := cfg.Auth.UserID(x)
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
return nil, vigo.ErrUnauthorized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sessions, err := auth.ListSessions(userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, vigo.ErrInternalServer.WithError(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentSID := auth.GetCurrentSessionID(x)
|
|
|
|
|
|
result := make([]SessionInfo, 0, len(sessions))
|
|
|
|
|
|
for _, s := range sessions {
|
|
|
|
|
|
result = append(result, SessionInfo{
|
|
|
|
|
|
ID: s.ID,
|
|
|
|
|
|
DeviceInfo: s.DeviceInfo,
|
|
|
|
|
|
IP: s.IP,
|
|
|
|
|
|
CreatedAt: s.CreatedAt,
|
|
|
|
|
|
ExpiresAt: s.ExpiresAt,
|
|
|
|
|
|
IsCurrent: s.ID == currentSID,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RevokeSessionRequest 撤销会话请求
|
|
|
|
|
|
type RevokeSessionRequest struct {
|
|
|
|
|
|
ID string `json:"id" src:"path" desc:"会话ID"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// revokeSession 撤销指定会话
|
|
|
|
|
|
func revokeSession(x *vigo.X, req *RevokeSessionRequest) error {
|
|
|
|
|
|
userID := cfg.Auth.UserID(x)
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
return vigo.ErrUnauthorized
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := auth.RevokeSession(userID, req.ID); err != nil {
|
|
|
|
|
|
return vigo.ErrInternalServer.WithError(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// revokeOtherSessions 批量撤销除当前外的所有会话
|
|
|
|
|
|
func revokeOtherSessions(x *vigo.X) error {
|
|
|
|
|
|
userID := cfg.Auth.UserID(x)
|
|
|
|
|
|
sessionID := auth.GetCurrentSessionID(x)
|
|
|
|
|
|
if userID == "" || sessionID == "" {
|
|
|
|
|
|
return vigo.ErrUnauthorized
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := auth.RevokeOtherSessions(userID, sessionID); err != nil {
|
|
|
|
|
|
return vigo.ErrInternalServer.WithError(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|