// Package auth 提供注册、登录、JWT 签发与鉴权中间件。 package auth import ( "crypto/rand" "encoding/hex" "errors" "fmt" "log/slog" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "rill/internal/config" "rill/internal/httpx" "rill/internal/model" "rill/internal/user" ) const ( tokenIssuer = "rill" authUserKey = "auth_user" defaultTokenTTL = 24 * time.Hour bearerPrefix = "Bearer " ) // dummyPasswordHash 账号不存在时仍执行一次 bcrypt 比对,避免通过响应时间枚举账号。 var dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("rill-dummy-password"), bcrypt.DefaultCost) // RegisterRequest 注册请求。 type RegisterRequest struct { Username string `json:"username" binding:"required,min=3,max=50" example:"alice"` Email string `json:"email" binding:"required,email,max=255" example:"alice@example.com"` Password string `json:"password" binding:"required,min=6,max=72" example:"secret123"` } // LoginRequest 登录请求,account 可填用户名或邮箱。 type LoginRequest struct { Account string `json:"account" binding:"required" example:"alice"` Password string `json:"password" binding:"required" example:"secret123"` } // LoginResponse 登录成功响应。 type LoginResponse struct { Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` ExpiresAt time.Time `json:"expires_at" example:"2026-09-21T10:00:00+08:00"` User model.User `json:"user"` } // @Summary Register // @Description Public registration. Creates a regular user in the default user group (id 1). username is 3-50 chars and unique; email is unique; password is 6-72 chars. // @Tags public // @Accept json // @Produce json // @Param user body auth.RegisterRequest true "Registration payload" // @Success 201 {object} model.User // @Failure 400 {object} httpx.ErrorResponse "invalid request" // @Failure 409 {object} httpx.ErrorResponse "username or email already exists" // @Failure 500 {object} httpx.ErrorResponse // @Router /auth/register [post] func Register(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { var req RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()}) return } ctx := c.Request.Context() hash, err := user.HashPassword(req.Password) if err != nil { httpx.RespondServerError(c, err, "生成密码哈希失败") return } newUser := model.User{ Username: req.Username, Email: req.Email, PasswordHash: string(hash), Status: 1, } err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&newUser).Error; err != nil { return err } return user.ReplaceGroups(tx, newUser.ID, []uint{model.GroupIDUser}) }) if err != nil { httpx.RespondDuplicateOrDBError(c, err, "username or email already exists") return } groups, err := user.LoadGroups(ctx, db, newUser.ID) if err != nil { httpx.RespondDBError(c, err) return } newUser.Groups = groups c.JSON(http.StatusCreated, newUser) } } // @Summary Login // @Description Login with username or email; returns a JWT (TTL from auth.token_ttl) and the user. // @Tags public // @Accept json // @Produce json // @Param credentials body auth.LoginRequest true "Login credentials" // @Success 200 {object} auth.LoginResponse // @Failure 400 {object} httpx.ErrorResponse "invalid request" // @Failure 401 {object} httpx.ErrorResponse "incorrect account or password" // @Failure 403 {object} httpx.ErrorResponse "account disabled" // @Failure 500 {object} httpx.ErrorResponse // @Router /auth/login [post] func Login(db *gorm.DB, authn *Authenticator) gin.HandlerFunc { return func(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()}) return } ctx := c.Request.Context() account := strings.TrimSpace(req.Account) var loginUser model.User err := db.WithContext(ctx). Where("username = ? OR email = ?", account, account). First(&loginUser).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { _ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(req.Password)) c.JSON(http.StatusUnauthorized, httpx.ErrorResponse{Error: "incorrect account or password"}) return } httpx.RespondDBError(c, err) return } if err := bcrypt.CompareHashAndPassword([]byte(loginUser.PasswordHash), []byte(req.Password)); err != nil { c.JSON(http.StatusUnauthorized, httpx.ErrorResponse{Error: "incorrect account or password"}) return } if loginUser.Status != 1 { c.JSON(http.StatusForbidden, httpx.ErrorResponse{Error: "account disabled"}) return } groups, err := user.LoadGroups(ctx, db, loginUser.ID) if err != nil { httpx.RespondDBError(c, err) return } loginUser.Groups = groups token, expiresAt, err := authn.Sign(loginUser.ID) if err != nil { slog.ErrorContext(ctx, "签发登录凭证失败", "err", err) c.JSON(http.StatusInternalServerError, httpx.ErrorResponse{Error: "internal server error"}) return } c.JSON(http.StatusOK, LoginResponse{Token: token, ExpiresAt: expiresAt, User: loginUser}) } } // Authenticator 负责 JWT 的签发与校验。 type Authenticator struct { secret []byte ttl time.Duration } // NewAuthenticator 依据配置构造认证器;secret 留空时生成临时密钥并告警。 func NewAuthenticator(cfg *config.Config) *Authenticator { secret := strings.TrimSpace(cfg.Auth.Secret) if secret == "" { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { slog.Error("生成临时 JWT 密钥失败", "err", err) } secret = hex.EncodeToString(buf) slog.Warn("auth.secret 未配置,已生成临时密钥,重启后登录状态将失效") } ttl := cfg.TokenTTLDuration() if ttl <= 0 { ttl = defaultTokenTTL } return &Authenticator{secret: []byte(secret), ttl: ttl} } // Sign 为用户签发登录凭证,返回 token 与过期时间。 func (a *Authenticator) Sign(userID uint) (string, time.Time, error) { now := time.Now() expiresAt := now.Add(a.ttl) token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ Issuer: tokenIssuer, Subject: strconv.FormatUint(uint64(userID), 10), IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expiresAt), }) signed, err := token.SignedString(a.secret) if err != nil { return "", time.Time{}, err } return signed, expiresAt, nil } // Parse 校验登录凭证并返回用户 ID。 func (a *Authenticator) Parse(tokenString string) (uint, error) { claims := &jwt.RegisteredClaims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return a.secret, nil }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(tokenIssuer), jwt.WithExpirationRequired(), ) if err != nil || !token.Valid { return 0, errors.New("invalid token") } id, err := strconv.ParseUint(claims.Subject, 10, 64) if err != nil || id == 0 { return 0, errors.New("invalid token") } return uint(id), nil } // RequireAuth 校验 Bearer 凭证并加载当前用户到上下文。 func (a *Authenticator) RequireAuth(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { token, ok := bearerToken(c) if !ok { httpx.RespondUnauthorized(c) return } userID, err := a.Parse(token) if err != nil { httpx.RespondUnauthorized(c) return } ctx := c.Request.Context() var current model.User if err := db.WithContext(ctx).First(¤t, userID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { httpx.RespondUnauthorized(c) return } httpx.RespondDBError(c, err) return } if current.Status != 1 { c.AbortWithStatusJSON(http.StatusForbidden, httpx.ErrorResponse{Error: "account disabled"}) return } groups, err := user.LoadGroups(ctx, db, current.ID) if err != nil { httpx.RespondDBError(c, err) return } current.Groups = groups c.Set(authUserKey, current) c.Next() } } // RequireAdmin 要求当前用户属于 admin 组,需在 RequireAuth 之后使用。 func RequireAdmin() gin.HandlerFunc { return func(c *gin.Context) { current, ok := CurrentUser(c) if !ok { httpx.RespondUnauthorized(c) return } if !current.IsAdmin() { c.AbortWithStatusJSON(http.StatusForbidden, httpx.ErrorResponse{Error: "admin permission required"}) return } c.Next() } } // CurrentUser 读取 RequireAuth 写入的当前登录用户。 func CurrentUser(c *gin.Context) (model.User, bool) { value, ok := c.Get(authUserKey) if !ok { return model.User{}, false } current, ok := value.(model.User) return current, ok } func bearerToken(c *gin.Context) (string, bool) { header := c.GetHeader("Authorization") if len(header) <= len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) { return "", false } token := strings.TrimSpace(header[len(bearerPrefix):]) return token, token != "" }