Files
kevin 9bf397064d 新增文件上传接口与用户头像裁剪上传
- 新增 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 文档
2026-09-21 20:50:19 +08:00

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(&current, 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 != ""
}