mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
396 lines
14 KiB
Go
396 lines
14 KiB
Go
// Package handler — 移动端 /api/v1 认证端点。
|
|
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/truewhile/MeBox/internal/middleware"
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
"github.com/truewhile/MeBox/internal/repository"
|
|
"github.com/truewhile/MeBox/internal/service"
|
|
)
|
|
|
|
// mobileUserPayload 是 v1 返回的用户摘要。这里显式挑选字段,避免把仅服务端
|
|
// 使用的偏好和内部状态带进新契约。
|
|
type mobileUserPayload struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Nickname string `json:"nickname,omitempty"`
|
|
Role string `json:"role"`
|
|
Tier string `json:"tier"`
|
|
IsActive bool `json:"is_active"`
|
|
ForcePasswordReset bool `json:"force_password_reset"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
func toMobileUserPayload(u *model.User) *mobileUserPayload {
|
|
if u == nil {
|
|
return nil
|
|
}
|
|
return &mobileUserPayload{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
Nickname: u.Nickname,
|
|
Role: u.Role,
|
|
Tier: u.Tier,
|
|
IsActive: u.IsActive,
|
|
ForcePasswordReset: u.ForcePasswordReset,
|
|
ExpiresAt: u.ExpiredAt,
|
|
}
|
|
}
|
|
|
|
type mobileSessionPayload struct {
|
|
AuthSessionID string `json:"auth_session_id"`
|
|
DeviceID string `json:"device_id"`
|
|
DeviceName string `json:"device_name,omitempty"`
|
|
ClientName string `json:"client_name,omitempty"`
|
|
ClientVersion string `json:"client_version,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastUsedAt time.Time `json:"last_used_at"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
func toMobileSessionPayload(s *model.AuthSession) *mobileSessionPayload {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
return &mobileSessionPayload{
|
|
AuthSessionID: s.ID,
|
|
DeviceID: s.DeviceID,
|
|
DeviceName: s.DeviceName,
|
|
ClientName: s.ClientName,
|
|
ClientVersion: s.ClientVersion,
|
|
CreatedAt: s.CreatedAt,
|
|
LastUsedAt: s.LastUsedAt,
|
|
ExpiresAt: s.ExpiresAt,
|
|
}
|
|
}
|
|
|
|
type mobileLoginRequest struct {
|
|
Username string `json:"username" binding:"required"`
|
|
Password string `json:"password" binding:"required"`
|
|
DeviceID string `json:"device_id" binding:"required"`
|
|
DeviceName string `json:"device_name"`
|
|
ClientName string `json:"client_name"`
|
|
ClientVersion string `json:"client_version"`
|
|
}
|
|
|
|
type mobileRefreshRequest struct {
|
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
}
|
|
|
|
// mobileAuthRequired 是 v1 专用认证中间件:要求有效 JWT + 有效设备会话,
|
|
// 并统一输出 v1 错误契约。
|
|
func mobileAuthRequired(svc *service.Container) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
raw := middleware.ExtractToken(c)
|
|
if raw == "" {
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"access token required", false, 0)
|
|
return
|
|
}
|
|
claims, err := svc.Token.ValidateAccessToken(raw)
|
|
if err != nil {
|
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
|
"access token expired", false, 0)
|
|
return
|
|
}
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"invalid access token", false, 0)
|
|
return
|
|
}
|
|
if strings.TrimSpace(claims.Purpose) != "" {
|
|
writeMobileError(c, http.StatusForbidden, mobileCodePermissionDenied,
|
|
"token scope denied for this endpoint", false, 0)
|
|
return
|
|
}
|
|
sessionID := strings.TrimSpace(claims.Sid)
|
|
if sessionID == "" {
|
|
// v1 客户端必须使用登录时创建的会话;旧网页令牌不进入移动端契约。
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"auth session required; sign in again", false, 0)
|
|
return
|
|
}
|
|
session, err := svc.AuthSessions.Validate(c.Request.Context(), sessionID, claims.UserID)
|
|
if err != nil {
|
|
if service.IsTransientDatabaseLock(err) {
|
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
|
"auth session check temporarily unavailable", true, 2)
|
|
return
|
|
}
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
|
"auth session revoked or expired", false, 0)
|
|
return
|
|
}
|
|
c.Set(middleware.CtxUserID, claims.UserID)
|
|
c.Set(middleware.CtxUserRole, claims.Role)
|
|
c.Set(middleware.CtxUserTier, claims.Tier)
|
|
c.Set(middleware.CtxAuthSessionID, sessionID)
|
|
c.Set(mobileCtxAuthSession, session)
|
|
// 每个请求写一次 last_used_at 会在 SQLite 上造成不必要的写锁竞争;
|
|
// 一分钟内的重复请求不更新,足以支撑设备列表的“最近活动”。
|
|
if time.Since(session.LastUsedAt) > time.Minute {
|
|
svc.AuthSessions.Touch(c.Request.Context(), sessionID)
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// mobileLogoutAuthRequired 只校验 JWT 与 sid,不要求会话仍然有效。
|
|
// 退出必须幂等:同一个令牌重复调用 logout 也要返回成功,而不是在第二次
|
|
// 被会话校验拦成 401。
|
|
func mobileLogoutAuthRequired(svc *service.Container) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
raw := middleware.ExtractToken(c)
|
|
if raw == "" {
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"access token required", false, 0)
|
|
return
|
|
}
|
|
claims, err := svc.Token.ValidateAccessToken(raw)
|
|
if err != nil {
|
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
|
"access token expired", false, 0)
|
|
return
|
|
}
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"invalid access token", false, 0)
|
|
return
|
|
}
|
|
sessionID := strings.TrimSpace(claims.Sid)
|
|
if sessionID == "" {
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"auth session required; sign in again", false, 0)
|
|
return
|
|
}
|
|
c.Set(middleware.CtxUserID, claims.UserID)
|
|
c.Set(middleware.CtxAuthSessionID, sessionID)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func mobileLoginHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req mobileLoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
|
"username, password and device_id are required", false, 0)
|
|
return
|
|
}
|
|
req.Username = strings.TrimSpace(req.Username)
|
|
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
|
if req.Username == "" || req.DeviceID == "" || len(req.DeviceID) > 128 {
|
|
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
|
"invalid username or device_id", false, 0)
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
user, err := svc.Auth.Authenticate(ctx, req.Username, req.Password)
|
|
if err != nil {
|
|
writeMobileAuthError(c, log, err)
|
|
return
|
|
}
|
|
|
|
deviceName := strings.TrimSpace(req.DeviceName)
|
|
clientName := strings.TrimSpace(req.ClientName)
|
|
if clientName == "" {
|
|
clientName = "MeBox Android"
|
|
}
|
|
session, err := svc.AuthSessions.Create(ctx, service.CreateAuthSessionInput{
|
|
UserID: user.ID,
|
|
DeviceID: req.DeviceID,
|
|
DeviceName: deviceName,
|
|
ClientName: clientName,
|
|
ClientVersion: req.ClientVersion,
|
|
})
|
|
if err != nil {
|
|
if isTransientServiceError(err) {
|
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
|
"cannot create auth session right now", true, 2)
|
|
return
|
|
}
|
|
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
|
"invalid device information", false, 0)
|
|
return
|
|
}
|
|
|
|
tokens, err := svc.Token.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
|
if err != nil {
|
|
// 令牌签发失败时回收会话,避免留下永远无法使用的会话记录。
|
|
_ = svc.AuthSessions.Revoke(ctx, session.ID)
|
|
writeMobileAuthError(c, log, err)
|
|
return
|
|
}
|
|
if svc.Device != nil {
|
|
svc.Device.RecordLogin(ctx, user.ID, session.DeviceID, deviceName, clientName, c.ClientIP())
|
|
}
|
|
if svc.Audit != nil {
|
|
svc.Audit.RecordBestEffort(user.ID, "auth.mobile.login", user.Username, c.ClientIP(), "")
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"auth_session_id": session.ID,
|
|
"user": toMobileUserPayload(user),
|
|
"tokens": tokens,
|
|
})
|
|
}
|
|
}
|
|
|
|
func mobileRefreshHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req mobileRefreshRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
|
"refresh_token required", false, 0)
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
rt, err := svc.Repo.RefreshToken.FindByHash(ctx, repository.HashToken(req.RefreshToken))
|
|
if err != nil {
|
|
if isTransientServiceError(err) {
|
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
|
"token store temporarily unavailable", true, 2)
|
|
return
|
|
}
|
|
writeMobileAuthError(c, log, err)
|
|
return
|
|
}
|
|
// 只有 v1 登录签发的刷新令牌才带会话;旧网页令牌必须重新登录。
|
|
// 注意这里不能在 rt.Revoked 时提前返回:轮换宽限期内的幂等复用
|
|
// 由 TokenService 判定。
|
|
if rt == nil || strings.TrimSpace(rt.AuthSessionID) == "" {
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"refresh token is not bound to a device session; sign in again", false, 0)
|
|
return
|
|
}
|
|
if _, err := svc.AuthSessions.Validate(ctx, rt.AuthSessionID, rt.UserID); err != nil {
|
|
if isTransientServiceError(err) {
|
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
|
"auth session check temporarily unavailable", true, 2)
|
|
return
|
|
}
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
|
"auth session revoked or expired", false, 0)
|
|
return
|
|
}
|
|
|
|
tokens, err := svc.Auth.RefreshTokens(ctx, req.RefreshToken)
|
|
if err != nil {
|
|
writeMobileAuthError(c, log, err)
|
|
return
|
|
}
|
|
user, err := svc.Repo.User.FindByID(ctx, rt.UserID)
|
|
if err != nil {
|
|
writeMobileAuthError(c, log, err)
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"user not found", false, 0)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"auth_session_id": rt.AuthSessionID,
|
|
"user": toMobileUserPayload(user),
|
|
"tokens": tokens,
|
|
})
|
|
}
|
|
}
|
|
|
|
func mobileSessionHandler(svc *service.Container) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
userID := c.GetString(middleware.CtxUserID)
|
|
user, err := svc.Repo.User.FindByID(ctx, userID)
|
|
if err != nil {
|
|
writeMobileAuthError(c, svc.Log, err)
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"user not found", false, 0)
|
|
return
|
|
}
|
|
session, _ := c.Get(mobileCtxAuthSession)
|
|
sessionModel, _ := session.(*model.AuthSession)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"auth_session_id": c.GetString(middleware.CtxAuthSessionID),
|
|
"user": toMobileUserPayload(user),
|
|
"session": toMobileSessionPayload(sessionModel),
|
|
})
|
|
}
|
|
}
|
|
|
|
func mobileLogoutHandler(svc *service.Container) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
sessionID := c.GetString(middleware.CtxAuthSessionID)
|
|
if err := svc.AuthSessions.Revoke(c.Request.Context(), sessionID); err != nil {
|
|
if isTransientServiceError(err) {
|
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
|
"cannot revoke session right now", true, 2)
|
|
return
|
|
}
|
|
writeMobileError(c, http.StatusInternalServerError, mobileCodeServiceUnavailable,
|
|
"logout failed", false, 0)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
// writeMobileAuthError 把 service 层错误映射为稳定错误码。
|
|
// 临时性存储/基础设施错误必须返回可重试的 503,不能伪装成凭证失效。
|
|
func writeMobileAuthError(c *gin.Context, log *zap.Logger, err error) {
|
|
switch {
|
|
case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidRefreshToken):
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
|
"invalid username, password or refresh token", false, 0)
|
|
case errors.Is(err, service.ErrTokenExpired):
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
|
"refresh token expired", false, 0)
|
|
case errors.Is(err, service.ErrTokenRevoked),
|
|
errors.Is(err, service.ErrAuthSessionRevoked),
|
|
errors.Is(err, service.ErrAuthSessionExpired),
|
|
errors.Is(err, service.ErrAuthSessionRequired):
|
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
|
"auth session revoked or expired", false, 0)
|
|
case errors.Is(err, service.ErrUserInactive):
|
|
writeMobileError(c, http.StatusForbidden, mobileCodeAccountDisabled,
|
|
"account is disabled", false, 0)
|
|
case errors.Is(err, service.ErrUserExpired):
|
|
writeMobileError(c, http.StatusForbidden, mobileCodeAccountExpired,
|
|
"account has expired", false, 0)
|
|
case isTransientServiceError(err):
|
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
|
"service temporarily unavailable, please retry", true, 2)
|
|
default:
|
|
if log != nil {
|
|
log.Warn("mobile auth request failed", zap.Error(err))
|
|
}
|
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
|
"service temporarily unavailable, please retry", true, 2)
|
|
}
|
|
}
|
|
|
|
func isTransientServiceError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if service.IsTransientDatabaseLock(err) {
|
|
return true
|
|
}
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|