// // Copyright (C) 2024 veypi // 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 }