初始化项目:基础设施 + 用户认证 + 后台管理系统 + AGPL 3.0 许可
This commit is contained in:
62
internal/common/cookie.go
Normal file
62
internal/common/cookie.go
Normal 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 Cookie(refresh 续期调用)
|
||||
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 清除所有认证 Cookie(logout 调用)
|
||||
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 Cookie(JS 可读)
|
||||
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
20
internal/common/crypto.go
Normal 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
22
internal/common/errors.go
Normal 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
44
internal/common/helper.go
Normal 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
|
||||
}
|
||||
51
internal/common/pagination.go
Normal file
51
internal/common/pagination.go
Normal 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
|
||||
20
internal/common/response.go
Normal file
20
internal/common/response.go
Normal 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})
|
||||
}
|
||||
51
internal/common/username.go
Normal file
51
internal/common/username.go
Normal 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
119
internal/config/config.go
Normal 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
|
||||
}
|
||||
131
internal/controller/admin/admin_controller.go
Normal file
131
internal/controller/admin/admin_controller.go
Normal 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, "操作失败")
|
||||
}
|
||||
}
|
||||
229
internal/controller/auth_controller.go
Normal file
229
internal/controller/auth_controller.go
Normal 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,
|
||||
})
|
||||
}
|
||||
92
internal/middleware/admin.go
Normal file
92
internal/middleware/admin.go
Normal 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 管理后台页面认证中间件
|
||||
// 逻辑同 AuthRequired(JWT 校验 + 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
146
internal/middleware/auth.go
Normal 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
112
internal/middleware/csrf.go
Normal 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=false,JS 可读——这是 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
|
||||
}
|
||||
149
internal/middleware/ratelimit.go
Normal file
149
internal/middleware/ratelimit.go
Normal 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()
|
||||
}
|
||||
}
|
||||
26
internal/middleware/security.go
Normal file
26
internal/middleware/security.go
Normal 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
15
internal/model/common.go
Normal 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
28
internal/model/dto.go
Normal 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
45
internal/model/user.go
Normal 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]
|
||||
}
|
||||
183
internal/repository/user_repo.go
Normal file
183
internal/repository/user_repo.go
Normal 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
104
internal/router/router.go
Normal 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))
|
||||
}
|
||||
|
||||
// --- 管理后台 API(JSON 响应) ---
|
||||
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)
|
||||
}
|
||||
}
|
||||
141
internal/service/admin_service.go
Normal file
141
internal/service/admin_service.go
Normal 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)
|
||||
}
|
||||
182
internal/service/auth_service.go
Normal file
182
internal/service/auth_service.go
Normal 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)
|
||||
}
|
||||
123
internal/service/token_service.go
Normal file
123
internal/service/token_service.go
Normal 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_expire(30天);false → 使用 refresh_expire(7天,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
53
internal/theme/loader.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user