初始化项目:基础设施 + 用户认证 + 后台管理系统 + AGPL 3.0 许可

This commit is contained in:
2026-05-26 13:46:33 +08:00
parent 1315df6501
commit 483fdd919f
56 changed files with 5804 additions and 40 deletions

62
internal/common/cookie.go Normal file
View File

@ -0,0 +1,62 @@
package common
import (
"log"
"net/http"
"metazone.cc/metalab/internal/config"
"github.com/gin-gonic/gin"
)
// Cookie 名称常量
const (
CookieName = "mlb_token"
RefreshCookieName = "mlb_refresh"
RMCookieName = "mlb_rm" // 记住我标记JS 可读
)
// SetAuthCookies 设置 access + refresh Cookie
// rememberMe=true → Cookie 持久化30天关浏览器后仍保持登录
// rememberMe=false → Cookie 用 session 模式maxAge=0关浏览器即清除但页面开启期间自动刷新
func SetAuthCookies(c *gin.Context, accessToken, refreshToken string, rememberMe bool, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode)
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
if rememberMe {
setCookie(c, RefreshCookieName, refreshToken, int(cfg.JWT.RememberExpire*3600), secure)
setPlainCookie(c, RMCookieName, "1", int(cfg.JWT.RememberExpire*3600), secure)
} else {
// session cookie关浏览器即清除但页面开启期间自动刷新生效
setCookie(c, RefreshCookieName, refreshToken, 0, secure)
setPlainCookie(c, RMCookieName, "1", 0, secure)
}
}
// SetAccessCookie 仅刷新 access Cookierefresh 续期调用)
func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode)
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
}
// ClearAuthCookies 清除所有认证 Cookielogout 调用)
func ClearAuthCookies(c *gin.Context, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetCookie(CookieName, "", -1, "/", "", secure, true)
c.SetCookie(RefreshCookieName, "", -1, "/", "", secure, true)
c.SetCookie(RMCookieName, "", -1, "/", "", secure, false)
}
// setCookie 写入一个 HttpOnly Cookie
func setCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
log.Printf("[setCookie] name=%s maxAge=%ds secure=%v", name, maxAge, secure)
c.SetCookie(name, value, maxAge, "/", "", secure, true)
}
// setPlainCookie 写入非 HttpOnly CookieJS 可读)
func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
c.SetCookie(name, value, maxAge, "/", "", secure, false)
}

20
internal/common/crypto.go Normal file
View File

@ -0,0 +1,20 @@
package common
import (
"golang.org/x/crypto/bcrypt"
)
// HashPassword 使用 bcrypt 哈希密码
func HashPassword(password string, cost int) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
return "", err
}
return string(bytes), nil
}
// CheckPassword 验证密码
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

22
internal/common/errors.go Normal file
View File

@ -0,0 +1,22 @@
package common
import "errors"
// 认证相关错误哨兵(统一存放,避免 service 和 middleware 交叉引用)
var (
ErrEmailExists = errors.New("该邮箱已注册")
ErrUsernameTaken = errors.New("该用户名已被占用")
ErrInvalidCred = errors.New("邮箱或密码错误")
ErrUserNotFound = errors.New("用户不存在")
ErrUserBanned = errors.New("该账号已被封禁")
ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复")
ErrUserLocked = errors.New("邮箱或密码错误")
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
ErrTokenExpired = errors.New("登录已过期,请重新登录")
ErrTokenInvalid = errors.New("无效的认证凭据")
ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
ErrRateLimitAccount = errors.New("该账号登录尝试过于频繁")
ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试")
ErrRateLimitRegisterIP = errors.New("注册请求过于频繁,请稍后重试")
ErrPermissionDenied = errors.New("权限不足")
)

44
internal/common/helper.go Normal file
View File

@ -0,0 +1,44 @@
package common
import (
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// BuildPageData 构建页面模板数据,自动注入登录状态与 CSRF token
func BuildPageData(c *gin.Context, extra gin.H) gin.H {
data := gin.H{}
for k, v := range extra {
data[k] = v
}
if username, exists := c.Get("username"); exists {
data["IsLoggedIn"] = true
data["Username"] = username
}
// 注入 CSRF token由 CSRF 中间件设置到上下文中)
if token, exists := c.Get("csrf_token"); exists {
data["CSRFToken"] = token
}
return data
}
// BuildAdminPageData 构建管理后台模板数据
// 额外注入 UID、Role、CanManageUsers、CurrentPath 等权限信息
func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
data := BuildPageData(c, extra)
data["IsAdmin"] = true
data["CurrentPath"] = c.Request.URL.Path
if uid, exists := c.Get("uid"); exists {
data["UID"] = uid
}
if role, exists := c.Get("role"); exists {
roleStr := role.(string)
data["Role"] = roleStr
data["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin)
data["CanManageSettings"] = model.HasMinRole(roleStr, model.RoleOwner)
data["IsOwner"] = model.HasMinRole(roleStr, model.RoleOwner)
}
return data
}

View File

@ -0,0 +1,51 @@
package common
import "time"
// Pagination 分页请求参数
type Pagination struct {
Page int `form:"page" json:"page" binding:"min=1"`
PageSize int `form:"page_size" json:"page_size" binding:"min=1,max=100"`
}
// PaginatedResult 分页响应
type PaginatedResult struct {
Items interface{} `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// DefaultPagination 默认分页(未传参时使用)
func (p *Pagination) DefaultPagination() {
if p.Page < 1 {
p.Page = 1
}
if p.PageSize < 1 || p.PageSize > 100 {
p.PageSize = 20
}
}
// Offset 计算 SQL offset
func (p *Pagination) Offset() int {
return (p.Page - 1) * p.PageSize
}
// NewPaginatedResult 构建分页响应
func NewPaginatedResult(items interface{}, total int64, page, pageSize int) *PaginatedResult {
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &PaginatedResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}
}
// 固定时间格式,前后端统一
const TimeFormat = time.RFC3339

View File

@ -0,0 +1,20 @@
package common
import (
"github.com/gin-gonic/gin"
"net/http"
)
// 统一 JSON 响应
func Ok(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func OkMessage(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
}
func Error(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{"success": false, "message": message})
}

View File

@ -0,0 +1,51 @@
package common
import (
"crypto/rand"
"errors"
"math/big"
)
// usernameChars 随机用户名字符集(小写字母 + 数字)
const usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
const usernameLen = 10
// UsernameChecker 用户名查重接口(避免 common 反向依赖 repository
type UsernameChecker interface {
ExistsByUsername(username string) (bool, error)
}
// GenerateUsername 生成不重复的随机 10 位用户名(小写字母 + 数字)
// checker 提供去重查询maxRetry 次重试后仍冲突则返回错误
func GenerateUsername(checker UsernameChecker, maxRetry int) (string, error) {
if maxRetry <= 0 {
maxRetry = 20
}
for i := 0; i < maxRetry; i++ {
username, err := randomUsername(usernameLen)
if err != nil {
return "", err
}
exists, err := checker.ExistsByUsername(username)
if err != nil {
return "", err
}
if !exists {
return username, nil
}
}
return "", errors.New("生成用户名失败,请重试")
}
// randomUsername 通过 crypto/rand 生成安全的随机字符串
func randomUsername(n int) (string, error) {
b := make([]byte, n)
for i := range b {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(usernameChars))))
if err != nil {
return "", err
}
b[i] = usernameChars[idx.Int64()]
}
return string(b), nil
}

119
internal/config/config.go Normal file
View File

@ -0,0 +1,119 @@
package config
import (
"bufio"
"log"
"os"
"strings"
"github.com/spf13/viper"
)
// Config 应用总配置
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"`
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
}
type ServerConfig struct {
Port string `mapstructure:"port"`
Mode string `mapstructure:"mode"`
}
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port string `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
SSLMode string `mapstructure:"sslmode"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
AccessExpire int `mapstructure:"access_expire"` // 分钟
RefreshExpire int `mapstructure:"refresh_expire"` // 小时
RememberExpire int `mapstructure:"remember_expire"` // 小时
}
type BcryptConfig struct {
Cost int `mapstructure:"cost"`
}
// 全局配置实例(初始化后只读)
var App *Config
// Load 加载配置config.yaml → .env 覆盖
func Load(configPath string) *Config {
// 1. 从 .env 加载环境变量(优先于 config.yaml
loadEnvFile(".env")
v := viper.New()
// 2. 读取 config.yaml
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
log.Fatalf("读取配置文件失败: %v", err)
}
// 3. 环境变量覆盖DATABASE_PASSWORD → database.password
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
bindEnvOverride(v)
c := &Config{}
if err := v.Unmarshal(c); err != nil {
log.Fatalf("解析配置失败: %v", err)
}
log.Printf("[Config] JWT.SecretLen=%d JWT.AccessExpire=%d min JWT.RefreshExpire=%d h JWT.RememberExpire=%d h | Server.Mode=%s",
len(c.JWT.Secret), c.JWT.AccessExpire, c.JWT.RefreshExpire, c.JWT.RememberExpire, c.Server.Mode)
App = c
return c
}
// loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时)
func loadEnvFile(path string) {
f, err := os.Open(path)
if err != nil {
return // .env 不存在,跳过
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if os.Getenv(key) == "" {
os.Setenv(key, val)
}
}
}
// bindEnvOverride 将环境变量映射到 config 的嵌套键
func bindEnvOverride(v *viper.Viper) {
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
_ = v.BindEnv("jwt.secret", "JWT_SECRET")
}
// DSN 返回 PostgreSQL 连接字符串
func (d DatabaseConfig) DSN() string {
return "host=" + d.Host +
" port=" + d.Port +
" user=" + d.User +
" password=" + d.Password +
" dbname=" + d.DBName +
" sslmode=" + d.SSLMode
}

View File

@ -0,0 +1,131 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
)
// AdminController 管理后台控制器
type AdminController struct {
adminService *service.AdminService
}
// NewAdminController 构造函数
func NewAdminController(adminService *service.AdminService) *AdminController {
return &AdminController{adminService: adminService}
}
// Dashboard 管理首页(欢迎页)
func (ac *AdminController) Dashboard(c *gin.Context) {
c.HTML(http.StatusOK, "admin/dashboard/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "管理首页",
}))
}
// UsersPage 用户管理页面
func (ac *AdminController) UsersPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/users/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "用户管理",
"ExtraCSS": "/admin/static/css/users.css",
"ExtraJS": "/admin/static/js/users.js",
}))
}
// ListUsers 用户列表 API分页 + 搜索 + 筛选)
func (ac *AdminController) ListUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
params := service.ListUsersParams{
Keyword: c.Query("keyword"),
Role: c.Query("role"),
Status: c.Query("status"),
Page: page,
PageSize: pageSize,
}
result, err := ac.adminService.ListUsers(params)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, result)
}
// UpdateUserStatus 封禁/解封用户
func (ac *AdminController) UpdateUserStatus(c *gin.Context) {
targetUID, err := parseUIDParam(c)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户 ID")
return
}
var req struct {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
operatorUID := c.GetUint("uid")
if err := ac.adminService.UpdateUserStatus(operatorUID, targetUID, req.Status); err != nil {
handleServiceError(c, err)
return
}
action := "封禁"
if req.Status == model.StatusActive {
action = "解封"
} else if req.Status == model.StatusLocked {
action = "已删除"
}
common.OkMessage(c, action+"成功")
}
// ResetToken 强制用户下线
func (ac *AdminController) ResetToken(c *gin.Context) {
targetUID, err := parseUIDParam(c)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户 ID")
return
}
operatorUID := c.GetUint("uid")
if err := ac.adminService.ResetUserToken(operatorUID, targetUID); err != nil {
handleServiceError(c, err)
return
}
common.OkMessage(c, "已强制下线")
}
// parseUIDParam 从 URL 路径参数解析 uid
func parseUIDParam(c *gin.Context) (uint, error) {
uid, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
return 0, err
}
return uint(uid), nil
}
// handleServiceError 统一处理 service 层返回的错误
func handleServiceError(c *gin.Context, err error) {
switch err {
case common.ErrPermissionDenied:
common.Error(c, http.StatusForbidden, "权限不足")
case common.ErrUserNotFound:
common.Error(c, http.StatusNotFound, "用户不存在")
case common.ErrEmailExists:
common.Error(c, http.StatusConflict, "该邮箱已被其他用户注册,无法解锁")
default:
common.Error(c, http.StatusInternalServerError, "操作失败")
}
}

View File

@ -0,0 +1,229 @@
package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/theme"
"github.com/gin-gonic/gin"
)
// AuthController 认证相关页面 + API
type AuthController struct {
authService *service.AuthService
tokenService *service.TokenService
rateLimiter *middleware.RateLimiter
cfg *config.Config
}
// NewAuthController 构造函数
func NewAuthController(authService *service.AuthService, tokenSvc *service.TokenService, limiter *middleware.RateLimiter, cfg *config.Config) *AuthController {
return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg}
}
// RegisterPage 注册页面(已登录用户重定向到首页)
func (ac *AuthController) RegisterPage(c *gin.Context) {
if _, exists := c.Get("uid"); exists {
c.Redirect(http.StatusMovedPermanently, "/")
return
}
guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
if err != nil {
c.String(http.StatusInternalServerError, "加载准则失败")
return
}
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
"Title": "注册",
"ExtraCSS": "/static/css/auth.css",
"Guidelines": guidelines,
}))
}
// LoginPage 登录页面(已登录用户重定向到首页)
func (ac *AuthController) LoginPage(c *gin.Context) {
if _, exists := c.Get("uid"); exists {
c.Redirect(http.StatusMovedPermanently, "/")
return
}
c.HTML(http.StatusOK, "auth/login.html", common.BuildPageData(c, gin.H{
"Title": "登录",
"ExtraCSS": "/static/css/auth.css",
}))
}
// clientIP 获取客户端真实 IP考虑反向代理
func clientIP(c *gin.Context) string {
if fwd := c.GetHeader("X-Forwarded-For"); fwd != "" {
return fwd
}
if real := c.GetHeader("X-Real-IP"); real != "" {
return real
}
return c.ClientIP()
}
// Login 登录 API含双维度限流
func (ac *AuthController) Login(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请检查输入")
return
}
email := req.Email
ip := clientIP(c)
// --- 限流:账户维度 ---
acctResult, recordAccount := ac.rateLimiter.AllowAccount(email)
if acctResult.Blocked {
common.Error(c, http.StatusTooManyRequests, acctResult.Message)
return
}
// --- 限流IP 维度 ---
ipResult, recordIP := ac.rateLimiter.AllowIP(ip)
if ipResult.Blocked {
common.Error(c, http.StatusTooManyRequests, ipResult.Message)
return
}
accessToken, refreshToken, user, err := ac.authService.Login(req)
if err != nil {
// 记录失败 → 两个维度各 +1
if recordAccount != nil {
recordAccount()
}
if recordIP != nil {
recordIP()
}
switch err {
case service.ErrInvalidCred:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case service.ErrUserBanned:
common.Error(c, http.StatusForbidden, "账号已被封禁")
case service.ErrUserLocked:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case service.ErrUserDeleted:
// deleted 状态本应在 Login 中自动恢复,此 case 作为兜底
common.Error(c, http.StatusForbidden, "该账号已申请注销,登录即自动恢复")
default:
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
}
return
}
// 登录成功 → 清除失败计数
ac.rateLimiter.Clear(email, ip)
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "登录成功",
"data": user,
})
}
// CheckEmail 检查邮箱是否已注册
func (ac *AuthController) CheckEmail(c *gin.Context) {
var req model.CheckEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请提供有效的邮箱地址")
return
}
exists, err := ac.authService.CheckEmail(req.Email)
if err != nil {
common.Error(c, http.StatusInternalServerError, "检查失败")
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{"exists": exists},
})
}
// Register 注册 API
func (ac *AuthController) Register(c *gin.Context) {
var req model.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请检查输入:"+err.Error())
return
}
// 注册 IP 限流1 分钟 5 次
regResult, recordReg := ac.rateLimiter.AllowIP(clientIP(c) + ":register")
if regResult.Blocked {
common.Error(c, http.StatusTooManyRequests, "注册请求过于频繁,请稍后重试")
return
}
accessToken, refreshToken, user, err := ac.authService.Register(req)
if err != nil {
if recordReg != nil {
recordReg()
}
switch err {
case service.ErrEmailExists:
common.Error(c, http.StatusConflict, "该邮箱已注册")
case service.ErrWeakPassword:
common.Error(c, http.StatusBadRequest, err.Error())
default:
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
}
return
}
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "注册成功!欢迎加入 MetaLab",
"data": user,
})
}
// Logout 退出登录:清除所有认证 Cookie
func (ac *AuthController) Logout(c *gin.Context) {
common.ClearAuthCookies(c, ac.cfg)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已退出登录",
})
}
// RefreshToken 用 refresh token 换取新的 access token
func (ac *AuthController) RefreshToken(c *gin.Context) {
refreshToken, err := c.Cookie(common.RefreshCookieName)
if err != nil {
common.Error(c, http.StatusUnauthorized, "请重新登录")
return
}
accessToken, _, err := ac.tokenService.RefreshAccessToken(refreshToken)
if err != nil {
common.ClearAuthCookies(c, ac.cfg)
switch err {
case service.ErrTokenExpired, service.ErrTokenRevoked:
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
case service.ErrUserBanned:
common.Error(c, http.StatusForbidden, "账号已被封禁")
default:
common.Error(c, http.StatusUnauthorized, "请重新登录")
}
return
}
common.SetAccessCookie(c, accessToken, ac.cfg)
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}

View File

@ -0,0 +1,92 @@
package middleware
import (
"log"
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"github.com/gin-gonic/gin"
)
// AdminAuth 管理后台页面认证中间件
// 逻辑同 AuthRequiredJWT 校验 + token_version 吊销检查)
// 但失败时重定向到首页,而非返回 JSON
// 成功时向 context 注入 uid/email/username/role
func AdminAuth(cfg *config.Config, userRepo *repository.UserRepo) gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr, err := c.Cookie(common.CookieName)
if err != nil {
log.Printf("[AdminAuth] NO_COOKIE path=%s → 302", c.Request.URL.Path)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
}
claims, err := parseToken(tokenStr, cfg.JWT.Secret)
if err != nil {
log.Printf("[AdminAuth] TOKEN_PARSE_FAIL path=%s err=%v → 302", c.Request.URL.Path, err)
common.ClearAuthCookies(c, cfg)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
}
uid := uint(claims["uid"].(float64))
tokenVer := int(claims["ver"].(float64))
// 即时吊销检查
currentVer, err := userRepo.FindTokenVersion(uid)
if err != nil || tokenVer != currentVer {
if tokenVer != currentVer {
log.Printf("[AdminAuth] REVOKED: uid=%d tokenVer=%d dbVer=%d → 302", uid, tokenVer, currentVer)
} else {
log.Printf("[AdminAuth] DB_ERR: uid=%d err=%v → 302", uid, err)
}
common.ClearAuthCookies(c, cfg)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
}
log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uid, claims["role"], c.Request.URL.Path)
c.Set("uid", uid)
c.Set("email", claims["email"])
c.Set("username", claims["username"])
c.Set("role", claims["role"])
c.Next()
}
}
// RequireMinRole 角色权限检查(用于 API 路由)
// 不足时返回 JSON 403
func RequireMinRole(minRole string) gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists || !model.HasMinRole(role.(string), minRole) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false, "message": "权限不足",
})
return
}
c.Next()
}
}
// RequirePageRole 角色权限检查(用于 SSR 页面路由)
// 不足时 302 跳转首页
func RequirePageRole(minRole string) gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists || !model.HasMinRole(role.(string), minRole) {
log.Printf("[RequirePageRole] DENY: role=%v need=%s path=%s → 302", role, minRole, c.Request.URL.Path)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
}
c.Next()
}
}

146
internal/middleware/auth.go Normal file
View File

@ -0,0 +1,146 @@
package middleware
import (
"log"
"strings"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/repository"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验)
type AuthMiddleware struct {
cfg *config.Config
userRepo *repository.UserRepo
}
// NewAuthMiddleware 构造函数
func NewAuthMiddleware(cfg *config.Config, userRepo *repository.UserRepo) *AuthMiddleware {
return &AuthMiddleware{cfg: cfg, userRepo: userRepo}
}
// Required 登录认证中间件:校验 JWT → 检查 token_version → 注入用户信息
// 令牌版本与 DB 不匹配时即时拒绝,实现实时吊销
func (am *AuthMiddleware) Required() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr, err := c.Cookie(common.CookieName)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{
"success": false, "message": "请先登录",
})
return
}
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
if err != nil {
common.ClearAuthCookies(c, am.cfg)
c.AbortWithStatusJSON(401, gin.H{
"success": false, "message": "登录已过期,请重新登录",
})
return
}
uid := uint(claims["uid"].(float64))
tokenVer := int(claims["ver"].(float64))
// 即时吊销检查:查询 DB 当前版本号,不匹配则拒绝
currentVer, err := am.userRepo.FindTokenVersion(uid)
if err != nil || tokenVer != currentVer {
if tokenVer != currentVer {
log.Printf("[AuthRequired] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
}
common.ClearAuthCookies(c, am.cfg)
c.AbortWithStatusJSON(401, gin.H{
"success": false, "message": "登录凭证已失效,请重新登录",
})
return
}
// 注入用户信息到上下文
c.Set("uid", uid)
c.Set("email", claims["email"])
c.Set("username", claims["username"])
c.Set("role", claims["role"])
c.Next()
}
}
// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行(仅清除过期 Cookie
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr, err := c.Cookie(common.CookieName)
if err != nil {
log.Println("[AuthOptional] no cookie for", c.Request.URL.Path)
c.Next()
return
}
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
if err != nil {
log.Printf("[AuthOptional] JWT parse failed for %s: %v", c.Request.URL.Path, err)
c.Next()
return
}
uid := uint(claims["uid"].(float64))
tokenVer := int(claims["ver"].(float64))
// 即时吊销检查
currentVer, err := am.userRepo.FindTokenVersion(uid)
if err != nil || tokenVer != currentVer {
log.Printf("[AuthOptional] REVOKED or err: uid=%d tokenVer=%d dbVer=%d err=%v",
uid, tokenVer, currentVer, err)
// 已吊销:清除 Cookie但仍放行页面视图前端自行处理
c.Next()
return
}
log.Printf("[AuthOptional] OK: uid=%v username=%v path=%s", claims["uid"], claims["username"], c.Request.URL.Path)
c.Set("uid", uid)
c.Set("email", claims["email"])
c.Set("username", claims["username"])
c.Set("role", claims["role"])
c.Next()
}
}
// parseToken 解析并验证 JWT
func parseToken(tokenStr, secret string) (jwt.MapClaims, error) {
now := time.Now()
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
log.Printf("[parseToken] FAIL: now=%v err=%v (secret_len=%d token_len=%d)",
now, err, len(secret), len(tokenStr))
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, jwt.ErrSignatureInvalid
}
// Debug: 打印 exp/iat 对比当前时间
if exp, exists := claims["exp"]; exists {
var expTime time.Time
switch v := exp.(type) {
case float64:
expTime = time.Unix(int64(v), 0)
case *jwt.NumericDate:
expTime = v.Time
}
if !expTime.IsZero() {
log.Printf("[parseToken] OK: exp=%v (%d) now=%v isExpired=%v",
expTime, expTime.Unix(), now, now.After(expTime))
}
}
return claims, nil
}
// IsLoginPage 检查是否已在登录状态,已登录用户跳过登录/注册页
func IsLoginPage(c *gin.Context) bool {
return strings.HasPrefix(c.Request.URL.Path, "/auth/")
}

112
internal/middleware/csrf.go Normal file
View File

@ -0,0 +1,112 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"net/http"
"metazone.cc/metalab/internal/config"
"github.com/gin-gonic/gin"
)
const (
csrfCookieName = "mlb_csrf"
csrfHeaderName = "X-CSRF-Token"
csrfMetaName = "csrf_token"
csrfTokenLen = 32 // 字节
)
// CSRF 中间件Double Submit Cookie 模式
// 前端 JS 从 Cookie 中读取 token 并放入 X-CSRF-Token 请求头,后端验证两者一致
// GET / HEAD / OPTIONS 请求自动放行
func CSRF(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// 安全读取豁免
if c.Request.Method == http.MethodGet ||
c.Request.Method == http.MethodHead ||
c.Request.Method == http.MethodOptions {
c.Next()
return
}
cookieToken, err := c.Cookie(csrfCookieName)
if err != nil || cookieToken == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false, "message": "CSRF 验证失败",
})
return
}
headerToken := c.GetHeader(csrfHeaderName)
// Fallback如果请求头没有尝试从表单字段读取纯 HTML form 提交通道)
if headerToken == "" {
headerToken = c.PostForm(csrfMetaName)
}
if headerToken == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false, "message": "CSRF 验证失败:缺少令牌",
})
return
}
// 恒定时间比较防时序攻击
if !constantTimeEq(cookieToken, headerToken) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false, "message": "CSRF 验证失败",
})
return
}
// 注入到上下文,供前端 <meta> 使用
c.Set(csrfMetaName, cookieToken)
c.Next()
}
}
// SetCSRFToken 在首次页面访问时下发 CSRF Cookie由页面路由中间件调用
// 注意:此 Cookie HttpOnly=falseJS 可读——这是 Double Submit 模式的必要条件
func SetCSRFToken(c *gin.Context, cfg *config.Config) string {
secure := cfg.Server.Mode != "debug"
// 如果已有 token 且未过期,复用(但必须注入 context供模板 meta 标签使用)
if existing, err := c.Cookie(csrfCookieName); err == nil && existing != "" {
c.Set(csrfMetaName, existing)
return existing
}
token, err := generateCSRFToken()
if err != nil {
// 极端情况:随机数生成失败,使用短 token
token = "fallback-" + hex.EncodeToString([]byte("metazone"))
}
c.SetSameSite(http.SameSiteStrictMode)
// 30 天有效期,与 refresh token 对齐
maxAge := int(cfg.JWT.RememberExpire * 3600)
c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false)
c.Set(csrfMetaName, token)
return token
}
// generateCSRFToken 生成 64 字符十六进制随机 CSRF token
func generateCSRFToken() (string, error) {
b := make([]byte, csrfTokenLen)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// constantTimeEq 恒定时间字符串比较(防时序攻击)
func constantTimeEq(a, b string) bool {
if len(a) != len(b) {
return false
}
var result byte
for i := 0; i < len(a); i++ {
result |= a[i] ^ b[i]
}
return result == 0
}

View File

@ -0,0 +1,149 @@
package middleware
import (
"fmt"
"sync"
"time"
)
// RateLimiter 双维度滑动窗口登录限流
// 维度一(账户):同邮箱 1 分钟内失败 3 次 → 锁定 1 分钟
// 维度二IP同 IP 1 分钟内失败 10 次 → 锁定 1 分钟
type RateLimiter struct {
mu sync.RWMutex
// accountFailures key = 邮箱(小写)
accountFailures map[string]*windowState
// ipFailures key = IP
ipFailures map[string]*windowState
}
// windowState 单个维度的限流状态
type windowState struct {
count int
windowStart time.Time
blockedUntil time.Time // 零值为未封锁
}
// RateLimitResult 限流检查结果
type RateLimitResult struct {
Blocked bool
RetryAfter int // 剩余封锁秒数
Message string
}
const (
accountWindow = 1 * time.Minute
accountMaxFails = 3
accountBlockDur = 1 * time.Minute
ipWindow = 1 * time.Minute
ipMaxFails = 10
ipBlockDur = 1 * time.Minute
cleanupInterval = 2 * time.Minute
maxEntries = 10000 // 单维度最大条目数(防内存耗尽)
)
// NewRateLimiter 创建限流器并启动后台清理
func NewRateLimiter() *RateLimiter {
rl := &RateLimiter{
accountFailures: make(map[string]*windowState),
ipFailures: make(map[string]*windowState),
}
go rl.cleanupLoop()
return rl
}
// AllowAccount 检查账户维度是否允许登录尝试
// 返回 (result, 登录失败时应调用的记录函数)
func (rl *RateLimiter) AllowAccount(email string) (RateLimitResult, func()) {
return rl.check(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true)
}
// AllowIP 检查 IP 维度是否允许登录尝试
func (rl *RateLimiter) AllowIP(ip string) (RateLimitResult, func()) {
return rl.check(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false)
}
// check 核心检查逻辑
// lowKey: 是否需要脱敏日志true = 暗示账户存在,仅泄漏给已知该邮箱的人)
func (rl *RateLimiter) check(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) (RateLimitResult, func()) {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
state, exists := m[key]
if !exists {
state = &windowState{}
if len(m) < maxEntries {
m[key] = state
}
}
// 检查是否处于封锁期
if !state.blockedUntil.IsZero() && now.Before(state.blockedUntil) {
retry := int(state.blockedUntil.Sub(now).Seconds()) + 1
msg := "请求过于频繁,请稍后重试"
if lowKey {
msg = fmt.Sprintf("该账号登录尝试过于频繁,请 %d 秒后重试", retry)
}
return RateLimitResult{Blocked: true, RetryAfter: retry, Message: msg}, nil
}
// 窗口过期 → 重置
if now.Sub(state.windowStart) > window {
state.count = 0
state.windowStart = now
state.blockedUntil = time.Time{}
}
// 记录失败(由调用方在登录失败时调用)的闭包
recordFail := func() {
rl.mu.Lock()
defer rl.mu.Unlock()
s := m[key]
if s == nil {
return
}
if now.Sub(s.windowStart) > window {
s.count = 1
s.windowStart = now
return
}
s.count++
if s.count >= maxFails {
s.blockedUntil = now.Add(blockDur)
}
}
return RateLimitResult{Blocked: false}, recordFail
}
// Clear 登录成功后清除该 email 和 IP 的失败计数
func (rl *RateLimiter) Clear(email, ip string) {
rl.mu.Lock()
defer rl.mu.Unlock()
delete(rl.accountFailures, email)
delete(rl.ipFailures, ip)
}
// cleanupLoop 定期清理过期条目
func (rl *RateLimiter) cleanupLoop() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
now := time.Now()
clean := func(m map[string]*windowState) {
for k, v := range m {
// 封锁期已过且窗口已过期 → 删除
if (v.blockedUntil.IsZero() || now.After(v.blockedUntil)) &&
now.Sub(v.windowStart) > accountWindow+ipBlockDur {
delete(m, k)
}
}
}
clean(rl.accountFailures)
clean(rl.ipFailures)
rl.mu.Unlock()
}
}

View File

@ -0,0 +1,26 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// SecurityHeaders 添加安全相关 HTTP 响应头
// 作为纵深防御,不影响业务逻辑,纯附加
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Content-Security-Policy仅允许本站资源 + 内联样式/脚本
c.Header("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
// 禁止 MIME 类型嗅探
c.Header("X-Content-Type-Options", "nosniff")
// 禁止被 frame 嵌入(防点击劫持)
c.Header("X-Frame-Options", "DENY")
// 引用策略
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Next()
}
}

15
internal/model/common.go Normal file
View File

@ -0,0 +1,15 @@
package model
import (
"time"
"gorm.io/gorm"
)
// BaseModel 所有模型的公共字段
type BaseModel struct {
ID uint `gorm:"primarykey;column:uid" json:"uid"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}

28
internal/model/dto.go Normal file
View File

@ -0,0 +1,28 @@
package model
// RegisterRequest 注册请求
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"`
RememberMe bool `json:"remember_me"`
}
// LoginRequest 登录请求
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
RememberMe bool `json:"remember_me"`
}
// CheckEmailRequest 检查邮箱是否已注册
type CheckEmailRequest struct {
Email string `json:"email" binding:"required,email"`
}
// AuthResponse 认证响应(含 JWT
type AuthResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
User User `json:"user"`
}

45
internal/model/user.go Normal file
View File

@ -0,0 +1,45 @@
package model
// User 用户模型
type User struct {
BaseModel
Email string `gorm:"type:varchar(255);not null" json:"email"`
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
Username string `gorm:"type:varchar(16);uniqueIndex;not null" json:"username"`
Avatar string `gorm:"type:varchar(500);default:''" json:"avatar"`
Bio string `gorm:"type:text" json:"bio"`
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
}
// 角色常量
const (
RoleUser = "user"
RoleModerator = "moderator"
RoleAdmin = "admin"
RoleOwner = "owner"
)
// 状态常量
const (
StatusActive = "active"
StatusBanned = "banned"
StatusDeleted = "deleted" // 用户主动注销7 天内登录可恢复)
StatusLocked = "locked" // 永久锁定(管理员删除或注销满 7 天)
)
// roleLevel 角色层级映射(数字越大权限越高)
var roleLevel = map[string]int{
RoleUser: 0,
RoleModerator: 1,
RoleAdmin: 2,
RoleOwner: 3,
}
// HasMinRole 检查 role 是否达到 minRole 的权限级别
func HasMinRole(role, minRole string) bool {
return roleLevel[role] >= roleLevel[minRole]
}

View File

@ -0,0 +1,183 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// UserRepo 用户数据访问
type UserRepo struct {
db *gorm.DB
}
// NewUserRepo 构造函数
func NewUserRepo(db *gorm.DB) *UserRepo {
return &UserRepo{db: db}
}
// Create 创建用户
func (r *UserRepo) Create(user *model.User) error {
return r.db.Create(user).Error
}
// FindByEmail 按邮箱查找用户
func (r *UserRepo) FindByEmail(email string) (*model.User, error) {
var user model.User
err := r.db.Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
// FindByID 按 UID 查找用户
func (r *UserRepo) FindByID(id uint) (*model.User, error) {
var user model.User
err := r.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
// FindByUsername 按用户名查找
func (r *UserRepo) FindByUsername(username string) (*model.User, error) {
var user model.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
// ExistsByEmail 检查邮箱是否已注册
func (r *UserRepo) ExistsByEmail(email string) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).Where("email = ?", email).Count(&count).Error
return count > 0, err
}
// ExistsByUsername 检查用户名是否已存在
func (r *UserRepo) ExistsByUsername(username string) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error
return count > 0, err
}
// ExistsByEmailExclude 检查邮箱是否被其他 active 用户占用(解锁前冲突检查)
func (r *UserRepo) ExistsByEmailExclude(email string, excludeUID uint) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).
Where("email = ? AND uid != ?", email, excludeUID).
Count(&count).Error
return count > 0, err
}
// IncrementTokenVersion 递增用户令牌版本,使所有已签发的 JWT 即时失效
func (r *UserRepo) IncrementTokenVersion(userID uint) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", userID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
}
// FindTokenVersion 按 UID 查询当前令牌版本(轻量查询,仅 SELECT token_version
func (r *UserRepo) FindTokenVersion(userID uint) (int, error) {
var version int
err := r.db.Model(&model.User{}).
Select("token_version").
Where("uid = ?", userID).
Scan(&version).Error
return version, err
}
// FindByIDForAuth 认证专用查询:返回 uid/email/username/role/status/token_version
// 比 FindByID 轻量(不查 avatar、bio 等无用字段),避免全量 SELECT *
// 使用默认 scope排除软删除locked 用户不可持有有效 JWT
func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
var user model.User
err := r.db.Select("uid", "email", "username", "role", "status", "token_version").
First(&user, userID).Error
if err != nil {
return nil, err
}
return &user, nil
}
// FindByIDUnscoped 管理后台专用:含软删除用户,用于解锁/封禁等操作
func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
var user model.User
err := r.db.Unscoped().Select("uid", "email", "username", "role", "status", "token_version", "deleted_at").
First(&user, userID).Error
if err != nil {
return nil, err
}
return &user, nil
}
// UpdateStatus 更新用户状态含软删除用户locked 状态变更需要)
func (r *UserRepo) UpdateStatus(uid uint, status string) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("status", status).Error
}
// UpdateRole 更新用户角色(仅 owner 调用)
func (r *UserRepo) UpdateRole(uid uint, role string) error {
return r.db.Model(&model.User{}).Where("uid = ?", uid).Update("role", role).Error
}
// Update 全量更新用户信息
func (r *UserRepo) Update(user *model.User) error {
return r.db.Save(user).Error
}
// SoftDelete GORM 软删除(设 DeletedAt用于 locked 状态释放邮箱
func (r *UserRepo) SoftDelete(uid uint) error {
return r.db.Where("uid = ?", uid).Delete(&model.User{}).Error
}
// Restore 恢复软删除(清 DeletedAt
func (r *UserRepo) Restore(uid uint) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("deleted_at", nil).Error
}
// SearchUsers 综合搜索(含软删除用户)
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
query := r.db.Unscoped().Model(&model.User{})
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("email LIKE ? OR username LIKE ?", like, like)
}
if role != "" {
query = query.Where("role = ?", role)
}
if status != "" {
query = query.Where("status = ?", status)
}
var users []model.User
err := query.Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
return users, err
}
// CountSearchUsers 搜索结果总数(含软删除用户)
func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error) {
query := r.db.Unscoped().Model(&model.User{})
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("email LIKE ? OR username LIKE ?", like, like)
}
if role != "" {
query = query.Where("role = ?", role)
}
if status != "" {
query = query.Where("status = ?", status)
}
var count int64
err := query.Count(&count).Error
return count, err
}
// FindByStatus 按状态分页查询(回收站用,暂未暴露前端)
func (r *UserRepo) FindByStatus(status string, offset, limit int) ([]model.User, error) {
var users []model.User
err := r.db.Where("status = ?", status).Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
return users, err
}

104
internal/router/router.go Normal file
View File

@ -0,0 +1,104 @@
package router
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/controller"
adminCtrl "metazone.cc/metalab/internal/controller/admin"
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Setup 注册所有路由
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
// --- 全局中间件 ---
r.Use(middleware.SecurityHeaders())
// --- 依赖注入 ---
userRepo := repository.NewUserRepo(db)
tokenSvc := service.NewTokenService(cfg, userRepo)
authService := service.NewAuthService(userRepo, tokenSvc, cfg)
rateLimiter := middleware.NewRateLimiter()
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
// 管理后台
adminService := service.NewAdminService(userRepo)
adminController := adminCtrl.NewAdminController(adminService)
// --- 页面路由CSRF 仅下发 token不验证——页面 GET 被豁免) ---
pages := r.Group("/")
pages.Use(authMdw.Optional())
pages.Use(middleware.CSRF(cfg))
pages.Use(func(c *gin.Context) {
// 页面路由:确保每个页面都下发 CSRF Cookie
middleware.SetCSRFToken(c, cfg)
c.Next()
})
{
pages.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
"Title": "首页",
"ExtraCSS": "/static/css/home.css",
}))
})
}
// 认证页面(已登录自动跳走)
authPages := r.Group("/auth")
authPages.Use(authMdw.Optional())
authPages.Use(middleware.CSRF(cfg))
authPages.Use(func(c *gin.Context) {
middleware.SetCSRFToken(c, cfg)
c.Next()
})
{
authPages.GET("/register", authCtrl.RegisterPage)
authPages.GET("/login", authCtrl.LoginPage)
}
// --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
adminPages := r.Group("/admin")
adminPages.Use(middleware.AdminAuth(cfg, userRepo))
adminPages.Use(middleware.RequirePageRole(model.RoleModerator))
adminPages.Use(middleware.CSRF(cfg))
adminPages.Use(func(c *gin.Context) {
middleware.SetCSRFToken(c, cfg)
c.Next()
})
{
adminPages.GET("/", adminController.Dashboard)
adminPages.GET("/users", adminController.UsersPage,
middleware.RequirePageRole(model.RoleAdmin))
}
// --- 管理后台 APIJSON 响应) ---
adminAPI := r.Group("/api/admin")
adminAPI.Use(authMdw.Required())
adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminAPI.Use(middleware.CSRF(cfg))
{
adminAPI.GET("/users", adminController.ListUsers)
adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus)
adminAPI.POST("/users/:uid/reset-token", adminController.ResetToken)
}
// --- API 路由CSRF 严格验证) ---
api := r.Group("/api")
api.Use(middleware.CSRF(cfg))
{
api.POST("/auth/check-email", authCtrl.CheckEmail)
api.POST("/auth/register", authCtrl.Register)
api.POST("/auth/login", authCtrl.Login)
api.POST("/auth/logout", authCtrl.Logout)
api.POST("/auth/refresh", authCtrl.RefreshToken)
}
}

View File

@ -0,0 +1,141 @@
package service
import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
)
// AdminService 管理后台业务逻辑
// 集中处理所有权限边界检查,避免胖控制器
type AdminService struct {
userRepo *repository.UserRepo
}
// NewAdminService 构造函数
func NewAdminService(userRepo *repository.UserRepo) *AdminService {
return &AdminService{userRepo: userRepo}
}
// ListUsersParams 用户列表查询参数
type ListUsersParams struct {
Keyword string // 搜索关键字(邮箱/用户名)
Role string // 角色筛选
Status string // 状态筛选
Page int // 页码
PageSize int // 每页条数
}
// ListUsersResult 用户列表查询结果
type ListUsersResult struct {
Users []model.User `json:"users"`
Total int64 `json:"total"`
Page int `json:"page"`
}
// ListUsers 分页搜索用户列表
func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, error) {
if params.Page < 1 {
params.Page = 1
}
if params.PageSize < 1 || params.PageSize > 100 {
params.PageSize = 20
}
offset := (params.Page - 1) * params.PageSize
total, err := s.userRepo.CountSearchUsers(params.Keyword, params.Role, params.Status)
if err != nil {
return nil, err
}
users, err := s.userRepo.SearchUsers(params.Keyword, params.Role, params.Status, offset, params.PageSize)
if err != nil {
return nil, err
}
return &ListUsersResult{
Users: users,
Total: total,
Page: params.Page,
}, nil
}
// UpdateUserStatus 修改用户状态
// 权限规则:
// - 不可操作自己
// - Admin 只能设置 active/banned且只能操作 RoleUser
// - Owner 可设置 active/banned/locked可操作任何人除自己
func (s *AdminService) UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error {
// 校验状态值合法性
switch newStatus {
case model.StatusActive, model.StatusBanned, model.StatusLocked:
default:
return common.ErrPermissionDenied
}
return s.checkAndOperate(operatorUID, targetUID, func(operator, target *model.User) error {
// locked 相关操作仅 owner
if target.Status == model.StatusLocked || newStatus == model.StatusLocked {
if !model.HasMinRole(operator.Role, model.RoleOwner) {
return common.ErrPermissionDenied
}
}
if err := s.userRepo.UpdateStatus(target.ID, newStatus); err != nil {
return err
}
// 锁定(删除)→ GORM 软删除释放邮箱;解锁 → 先查邮箱冲突再恢复
if newStatus == model.StatusLocked {
if err := s.userRepo.SoftDelete(target.ID); err != nil {
return err
}
} else if target.Status == model.StatusLocked && newStatus == model.StatusActive {
// 检查邮箱是否已被新用户注册(软删期间邮箱释放了)
collision, err := s.userRepo.ExistsByEmailExclude(target.Email, target.ID)
if err != nil {
return err
}
if collision {
return common.ErrEmailExists
}
if err := s.userRepo.Restore(target.ID); err != nil {
return err
}
}
// 修改状态必须立刻让 JWT 失效
return s.userRepo.IncrementTokenVersion(target.ID)
})
}
// ResetUserToken 强制下线(递增 token_version
func (s *AdminService) ResetUserToken(operatorUID, targetUID uint) error {
return s.checkAndOperate(operatorUID, targetUID, func(_ *model.User, target *model.User) error {
return s.userRepo.IncrementTokenVersion(target.ID)
})
}
// checkAndOperate 通用权限检查 + 执行操作
func (s *AdminService) checkAndOperate(operatorUID, targetUID uint, operate func(*model.User, *model.User) error) error {
// 1. 不可操作自己
if operatorUID == targetUID {
return common.ErrPermissionDenied
}
// 2. 查操作者
operator, err := s.userRepo.FindByIDUnscoped(operatorUID)
if err != nil {
return common.ErrUserNotFound
}
// 3. 查目标含软删除用户locked 解锁需要查到)
target, err := s.userRepo.FindByIDUnscoped(targetUID)
if err != nil {
return common.ErrUserNotFound
}
// 4. Admin 只能操作 user 角色
if operator.Role == model.RoleAdmin && target.Role != model.RoleUser {
return common.ErrPermissionDenied
}
return operate(operator, target)
}

View File

@ -0,0 +1,182 @@
package service
import (
"regexp"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// AuthService 认证业务逻辑
type AuthService struct {
userRepo *repository.UserRepo
tokenService *TokenService
cfg *config.Config
}
// NewAuthService 构造函数
func NewAuthService(userRepo *repository.UserRepo, tokenSvc *TokenService, cfg *config.Config) *AuthService {
return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg}
}
var (
ErrEmailExists = common.ErrEmailExists
ErrUsernameTaken = common.ErrUsernameTaken
ErrInvalidCred = common.ErrInvalidCred
ErrUserNotFound = common.ErrUserNotFound
ErrUserBanned = common.ErrUserBanned
ErrUserLocked = common.ErrUserLocked
ErrUserDeleted = common.ErrUserDeleted
ErrWeakPassword = common.ErrWeakPassword
ErrTokenExpired = common.ErrTokenExpired
ErrTokenInvalid = common.ErrTokenInvalid
ErrTokenRevoked = common.ErrTokenRevoked
ErrPermissionDenied = common.ErrPermissionDenied
)
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
var pwDigit = regexp.MustCompile(`\d`)
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
// 在 init() 中按当前 bcrypt 成本生成,确保时序与真实校验一致
var dummyHash []byte
func init() {
hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
if err != nil {
panic("failed to generate bcrypt dummy hash: " + err.Error())
}
dummyHash = hash
}
// validatePassword 密码强度:至少 8 位 + 包含字母 + 包含数字
func validatePassword(pw string) error {
if len(pw) < 8 || !pwLetter.MatchString(pw) || !pwDigit.MatchString(pw) {
return ErrWeakPassword
}
return nil
}
// Register 注册
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT
// rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除
func (s *AuthService) Register(req model.RegisterRequest) (string, string, *model.User, error) {
// 1. 密码强度
if err := validatePassword(req.Password); err != nil {
return "", "", nil, err
}
// 2. 检查邮箱
exists, err := s.userRepo.ExistsByEmail(req.Email)
if err != nil {
return "", "", nil, err
}
if exists {
return "", "", nil, ErrEmailExists
}
// 3. 哈希密码
hash, err := common.HashPassword(req.Password, s.cfg.Bcrypt.Cost)
if err != nil {
return "", "", nil, err
}
// 4. 生成唯一用户名
username, err := common.GenerateUsername(s.userRepo, 20)
if err != nil {
return "", "", nil, err
}
// 5. 创建用户
user := &model.User{
Email: req.Email,
PasswordHash: hash,
Username: username,
Role: model.RoleUser,
Status: model.StatusActive,
}
if err := s.userRepo.Create(user); err != nil {
return "", "", nil, err
}
// 6. 生成 access JWT
accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil {
return "", "", nil, err
}
// 7. 生成 refresh JWT不勾选"记住我"也用 session cookie关浏览器即清除
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
}
// Login 登录
// 流程:按邮箱查找 → 检查状态 → 验证密码 → access JWT + refresh JWT
// 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对
// rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除
func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrInvalidCred
}
// 已注销deleted→ 自动恢复
if user.Status == model.StatusDeleted {
user.Status = model.StatusActive
user.DeletedAt = gorm.DeletedAt{}
if err := s.userRepo.Update(user); err != nil {
return "", "", nil, err
}
// 恢复后继续正常登录流程
}
// 永久锁定
if user.Status == model.StatusLocked {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrUserLocked
}
// 封禁
if user.Status == model.StatusBanned {
return "", "", nil, ErrUserBanned
}
if !common.CheckPassword(req.Password, user.PasswordHash) {
return "", "", nil, ErrInvalidCred
}
// 生成 access JWT
accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil {
return "", "", nil, err
}
// 生成 refresh JWT不勾选"记住我"也生成Cookie 用 session 模式)
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
}
// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查)
func (s *AuthService) CheckEmail(email string) (bool, error) {
return s.userRepo.ExistsByEmail(email)
}
// InvalidateSessions 吊销某用户所有 JWT递增 token_version强制所有设备重新登录
// 适用场景:修改密码、账号被盗、管理员强制下线
func (s *AuthService) InvalidateSessions(userID uint) error {
return s.userRepo.IncrementTokenVersion(userID)
}

View File

@ -0,0 +1,123 @@
package service
import (
"log"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"github.com/golang-jwt/jwt/v5"
)
// TokenService JWT 令牌签发与刷新
// 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用
type TokenService struct {
cfg *config.Config
userRepo *repository.UserRepo
}
// NewTokenService 构造函数
func NewTokenService(cfg *config.Config, userRepo *repository.UserRepo) *TokenService {
return &TokenService{cfg: cfg, userRepo: userRepo}
}
// BuildAccessToken 构建 access JWT含 uid/email/username/role/ver/exp/iat
// ver = user.TokenVersion用于即时吊销版本号不匹配则拒绝
func (ts *TokenService) BuildAccessToken(user *model.User) (string, error) {
expire := time.Duration(ts.cfg.JWT.AccessExpire) * time.Minute
now := time.Now()
expAt := now.Add(expire)
log.Printf("[BuildAccessToken] AccessExpire=%d min → expire=%v | now=%v | exp=%v (%d) | iat=%d ver=%d",
ts.cfg.JWT.AccessExpire, expire, now, expAt, expAt.Unix(), now.Unix(), user.TokenVersion)
claims := jwt.MapClaims{
"uid": user.ID,
"email": user.Email,
"username": user.Username,
"role": user.Role,
"ver": user.TokenVersion,
"exp": expAt.Unix(),
"iat": now.Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(ts.cfg.JWT.Secret))
}
// BuildRefreshToken 构建 refresh JWT含 purpose:"refresh" 防止 access 冒充)
// rememberMe=true → 使用 remember_expire30天false → 使用 refresh_expire7天session 模式)
// ver = user.TokenVersion刷新时校验版本号不匹配则拒绝
func (ts *TokenService) BuildRefreshToken(user *model.User, rememberMe bool) (string, error) {
var expireHours int
if rememberMe {
expireHours = ts.cfg.JWT.RememberExpire
} else {
expireHours = ts.cfg.JWT.RefreshExpire
}
expire := time.Duration(expireHours) * time.Hour
now := time.Now()
claims := jwt.MapClaims{
"uid": user.ID,
"email": user.Email,
"username": user.Username,
"role": user.Role,
"ver": user.TokenVersion,
"exp": now.Add(expire).Unix(),
"iat": now.Unix(),
"purpose": "refresh",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(ts.cfg.JWT.Secret))
}
// RefreshAccessToken 用 refresh token 换取新的 access token
// 校验 token_version若 DB 中版本已递增,拒绝刷新 → 即时吊销
func (ts *TokenService) RefreshAccessToken(refreshTokenStr string) (string, *model.User, error) {
if refreshTokenStr == "" {
return "", nil, ErrTokenInvalid
}
token, err := jwt.Parse(refreshTokenStr, func(t *jwt.Token) (interface{}, error) {
return []byte(ts.cfg.JWT.Secret), nil
})
if err != nil || !token.Valid {
return "", nil, ErrTokenExpired
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", nil, ErrTokenInvalid
}
// 只接受 refresh 用途的 token防止 access token 被用于刷新
if purpose, _ := claims["purpose"].(string); purpose != "refresh" {
return "", nil, ErrTokenInvalid
}
uid := uint(claims["uid"].(float64))
tokenVer := int(claims["ver"].(float64))
// 即时吊销检查 + 获取最新用户数据(角色/状态可能在 JWT 签发后已变更)
// 用 FindByIDForAuth 而非从 claims 重建,确保 access token 承载最新数据
user, err := ts.userRepo.FindByIDForAuth(uid)
if err != nil {
return "", nil, common.ErrTokenRevoked
}
if tokenVer != user.TokenVersion {
log.Printf("[RefreshAccessToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, user.TokenVersion)
return "", nil, common.ErrTokenRevoked
}
// 防止封禁用户通过 refresh 续期
if user.Status == model.StatusBanned {
return "", nil, common.ErrUserBanned
}
accessToken, err := ts.BuildAccessToken(user)
if err != nil {
return "", nil, err
}
return accessToken, user, nil
}

53
internal/theme/loader.go Normal file
View File

@ -0,0 +1,53 @@
package theme
import (
"html/template"
"os"
"path/filepath"
"strings"
)
// TemplateRoot 模板根目录配置
type TemplateRoot struct {
Dir string // 目录路径
Prefix string // 模板名前缀(如 "admin/" 表示管理后台)
}
// LoadTemplates 加载多个根目录的 .html 模板到同一模板集
// 模板名 = prefix + 相对于 root.Dir 的相对路径
func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
t := template.New("")
for _, root := range roots {
dir := filepath.Clean(root.Dir) + string(os.PathSeparator)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || filepath.Ext(path) != ".html" {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
// 模板名prefix + 相对路径
name := root.Prefix + strings.TrimPrefix(path, dir)
_, err = t.New(name).Parse(string(content))
return err
})
if err != nil {
return nil, err
}
}
return t, nil
}
// LoadContent 读取主题内容文件(准则等),返回不转义的 template.HTML。
// 后期改为查数据库时,只需修改此函数实现,调用方不变。
func LoadContent(path string) (template.HTML, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
return template.HTML(content), nil
}