- 新增 storage 配置(存储目录、单文件大小上限),ConfigVersion 3→4 自动补全 - internal/file:上传(sha256 秒传去重)、删除(上传者或管理员,引用中 409)、公开查看;本地存储 + 操作日志 + 引用计数,仅安全类型内联防存储型 XSS - internal/avatar:PUT/DELETE /api/me/avatar,自动管理头像文件引用与旧头像解绑 - 前端引入 vue-advanced-cropper,个人中心支持上传/更换/删除头像,裁剪输出 512×512 JPEG;http 请求支持 FormData - 导出 auth.CurrentUser、新增 model.User.IsAdmin 与 testutil 多部件上传辅助,补充接口测试并重新生成 Swagger 文档
311 lines
9.4 KiB
Go
311 lines
9.4 KiB
Go
// 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 != ""
|
|
}
|