feat: 封禁用户允许登录但禁止写操作

- Session 结构体新增 Status 字段,Create/Validate 同步状态
- AuthService.Login 移除封禁检查,允许封禁用户登录
- 新增 BannedWriteGuard 中间件,拦截 POST/PUT/DELETE 写操作
- 封禁用户自动签到跳过
- 所有需登录的 API 路由组应用 BannedWriteGuard
- AdminService.UpdateUserStatus 保留 DestroyByUID 确保状态刷新
This commit is contained in:
2026-05-31 21:20:04 +08:00
parent a84fa5da61
commit bbcd992614
7 changed files with 44 additions and 18 deletions

View File

@ -51,8 +51,6 @@ func (ac *AuthController) Login(c *gin.Context) {
switch err { switch err {
case common.ErrInvalidCred: case common.ErrInvalidCred:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrUserBanned:
common.Error(c, http.StatusForbidden, "账号已被封禁")
case common.ErrUserLocked: case common.ErrUserLocked:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrMaintenanceMode: case common.ErrMaintenanceMode:

View File

@ -5,6 +5,7 @@ import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session" "metazone.cc/metalab/internal/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -86,10 +87,14 @@ func (am *AuthMiddleware) Optional() gin.HandlerFunc {
// tryAutoCheckIn 尝试自动签到,并同步 session Exp 与 DB静默失败不影响主流程 // tryAutoCheckIn 尝试自动签到,并同步 session Exp 与 DB静默失败不影响主流程
// CheckIn 即使已签到也会返回 DB 中最新的 Exp这里始终与 session 比较, // CheckIn 即使已签到也会返回 DB 中最新的 Exp这里始终与 session 比较,
// 确保 CompleteTask 等非签到路径的 Exp 变化也能同步到 session避免 NAV 显示旧等级 // 确保 CompleteTask 等非签到路径的 Exp 变化也能同步到 session避免 NAV 显示旧等级
// 被封禁用户跳过自动签到
func (am *AuthMiddleware) tryAutoCheckIn(c *gin.Context, s *session.Session) { func (am *AuthMiddleware) tryAutoCheckIn(c *gin.Context, s *session.Session) {
if am.levelSvc == nil || s == nil { if am.levelSvc == nil || s == nil {
return return
} }
if s.Status == model.StatusBanned {
return
}
_, newExp, _, _ := am.levelSvc.CheckIn(s.UserID) _, newExp, _, _ := am.levelSvc.CheckIn(s.UserID)
// 只允许 Exp 只增不减DB 异常返回 0 或主从延迟读到旧值时不会回退 // 只允许 Exp 只增不减DB 异常返回 0 或主从延迟读到旧值时不会回退
if newExp > s.Exp { if newExp > s.Exp {
@ -109,6 +114,31 @@ func injectSessionContext(c *gin.Context, s *session.Session) {
c.Set("username", s.Username) c.Set("username", s.Username)
c.Set("avatar", s.Avatar) c.Set("avatar", s.Avatar)
c.Set("role", s.Role) c.Set("role", s.Role)
c.Set("status", s.Status)
c.Set("exp", s.Exp) c.Set("exp", s.Exp)
c.Set("sid", s.ID) c.Set("sid", s.ID)
} }
// BannedWriteGuard 封禁用户写操作守卫
// 仅拦截 POST/PUT/DELETE/PATCH 请求GET/HEAD/OPTIONS 放行
// 需在 Required() 之后调用,确保上下文中已有用户 status
func (am *AuthMiddleware) BannedWriteGuard() gin.HandlerFunc {
return func(c *gin.Context) {
// 只检查写操作
switch c.Request.Method {
case "GET", "HEAD", "OPTIONS":
c.Next()
return
}
status, _ := c.Get("status")
if status == model.StatusBanned {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false,
"message": "账号已被封禁,无法执行此操作",
})
return
}
c.Next()
}
}

View File

@ -34,6 +34,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminAPI := r.Group("/api/admin") adminAPI := r.Group("/api/admin")
adminAPI.Use(d.authMdw.Required()) adminAPI.Use(d.authMdw.Required())
adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin)) adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminAPI.Use(d.authMdw.BannedWriteGuard())
adminAPI.Use(middleware.CSRF(cfg)) adminAPI.Use(middleware.CSRF(cfg))
{ {
adminAPI.GET("/users", d.adminCtrl.ListUsers) adminAPI.GET("/users", d.adminCtrl.ListUsers)
@ -48,6 +49,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
siteSettingsAPI := r.Group("/api/admin/site-settings") siteSettingsAPI := r.Group("/api/admin/site-settings")
siteSettingsAPI.Use(d.authMdw.Required()) siteSettingsAPI.Use(d.authMdw.Required())
siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner)) siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner))
siteSettingsAPI.Use(d.authMdw.BannedWriteGuard())
siteSettingsAPI.Use(middleware.CSRF(cfg)) siteSettingsAPI.Use(middleware.CSRF(cfg))
{ {
siteSettingsAPI.GET("", d.siteSettingCtrl.GetSettings) siteSettingsAPI.GET("", d.siteSettingCtrl.GetSettings)
@ -60,6 +62,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPostAPI := r.Group("/api/admin/posts") adminPostAPI := r.Group("/api/admin/posts")
adminPostAPI.Use(d.authMdw.Required()) adminPostAPI.Use(d.authMdw.Required())
adminPostAPI.Use(middleware.RequireMinRole(model.RoleAdmin)) adminPostAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminPostAPI.Use(d.authMdw.BannedWriteGuard())
adminPostAPI.Use(middleware.CSRF(cfg)) adminPostAPI.Use(middleware.CSRF(cfg))
{ {
adminPostAPI.POST("/:id/approve", d.adminPostCtrl.Approve) adminPostAPI.POST("/:id/approve", d.adminPostCtrl.Approve)

View File

@ -12,6 +12,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 设置页 API需登录 --- // --- 设置页 API需登录 ---
settingsAPI := r.Group("/api/settings") settingsAPI := r.Group("/api/settings")
settingsAPI.Use(d.authMdw.Required()) settingsAPI.Use(d.authMdw.Required())
settingsAPI.Use(d.authMdw.BannedWriteGuard())
settingsAPI.Use(middleware.CSRF(cfg)) settingsAPI.Use(middleware.CSRF(cfg))
{ {
settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile) settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
@ -29,6 +30,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 消息中心 API需登录 --- // --- 消息中心 API需登录 ---
messagesAPI := r.Group("/api/messages") messagesAPI := r.Group("/api/messages")
messagesAPI.Use(d.authMdw.Required()) messagesAPI.Use(d.authMdw.Required())
messagesAPI.Use(d.authMdw.BannedWriteGuard())
messagesAPI.Use(middleware.CSRF(cfg)) messagesAPI.Use(middleware.CSRF(cfg))
{ {
messagesAPI.GET("/unread", d.messageCtrl.UnreadCount) messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
@ -39,6 +41,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 积分/等级 API需登录 --- // --- 积分/等级 API需登录 ---
levelAPI := r.Group("/api/level") levelAPI := r.Group("/api/level")
levelAPI.Use(d.authMdw.Required()) levelAPI.Use(d.authMdw.Required())
levelAPI.Use(d.authMdw.BannedWriteGuard())
levelAPI.Use(middleware.CSRF(cfg)) levelAPI.Use(middleware.CSRF(cfg))
{ {
levelAPI.POST("/checkin", d.levelCtrl.CheckIn) levelAPI.POST("/checkin", d.levelCtrl.CheckIn)
@ -64,6 +67,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 帖子 API需登录 --- // --- 帖子 API需登录 ---
postsAPI := r.Group("/api/posts") postsAPI := r.Group("/api/posts")
postsAPI.Use(d.authMdw.Required()) postsAPI.Use(d.authMdw.Required())
postsAPI.Use(d.authMdw.BannedWriteGuard())
postsAPI.Use(middleware.CSRF(cfg)) postsAPI.Use(middleware.CSRF(cfg))
{ {
postsAPI.POST("/:id/submit", d.postCtrl.Submit) postsAPI.POST("/:id/submit", d.postCtrl.Submit)
@ -73,6 +77,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 创作中心 API需登录 --- // --- 创作中心 API需登录 ---
studioAPI := r.Group("/api/studio") studioAPI := r.Group("/api/studio")
studioAPI.Use(d.authMdw.Required()) studioAPI.Use(d.authMdw.Required())
studioAPI.Use(d.authMdw.BannedWriteGuard())
studioAPI.Use(middleware.CSRF(cfg)) studioAPI.Use(middleware.CSRF(cfg))
{ {
studioAPI.GET("/overview", d.studioCtrl.OverviewAPI) studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)

View File

@ -148,12 +148,6 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User
return nil, common.ErrUserLocked return nil, common.ErrUserLocked
} }
// 封禁
if user.Status == model.StatusBanned {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrUserBanned
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil { if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
return nil, common.ErrInvalidCred return nil, common.ErrInvalidCred
} }

View File

@ -39,6 +39,7 @@ func (m *Manager) Create(user *model.User, rememberMe bool, ip, userAgent string
Username: user.Username, Username: user.Username,
Avatar: user.Avatar, Avatar: user.Avatar,
Role: user.Role, Role: user.Role,
Status: user.Status,
Exp: user.Exp, Exp: user.Exp,
RememberMe: rememberMe, RememberMe: rememberMe,
IP: ip, IP: ip,
@ -54,7 +55,7 @@ func (m *Manager) Create(user *model.User, rememberMe bool, ip, userAgent string
} }
// Validate 验证会话:读取 → 检查过期 → 检查用户状态 → 续期 → 返回 // Validate 验证会话:读取 → 检查过期 → 检查用户状态 → 续期 → 返回
// 返回 nil 表示会话无效(不存在/过期/用户被封禁 // 若用户不存在则返回 nil被封禁/锁定用户仍返回 session由上层中间件决定可访问范围
func (m *Manager) Validate(sid string) (*Session, error) { func (m *Manager) Validate(sid string) (*Session, error) {
if sid == "" { if sid == "" {
return nil, nil return nil, nil
@ -65,7 +66,7 @@ func (m *Manager) Validate(sid string) (*Session, error) {
return nil, nil return nil, nil
} }
// 检查用户是否仍然有效(封禁/注销后拒绝已有会话) // 检查用户是否仍然存在
user, err := m.userRepo.FindByIDForAuth(s.UserID) user, err := m.userRepo.FindByIDForAuth(s.UserID)
if err != nil { if err != nil {
log.Printf("[SessionManager] Validate: user not found uid=%d err=%v", s.UserID, err) log.Printf("[SessionManager] Validate: user not found uid=%d err=%v", s.UserID, err)
@ -73,17 +74,11 @@ func (m *Manager) Validate(sid string) (*Session, error) {
return nil, nil return nil, nil
} }
// 封禁用户直接拒绝 // 同步 DB 最新字段(头像/用户名/角色/状态/经验值等可能已变更)
if user.Status == model.StatusBanned {
log.Printf("[SessionManager] Validate: user banned uid=%d", s.UserID)
m.store.Delete(sid)
return nil, nil
}
// 同步 DB 最新字段(头像/用户名/角色/经验值等可能已变更)
s.Avatar = user.Avatar s.Avatar = user.Avatar
s.Username = user.Username s.Username = user.Username
s.Role = user.Role s.Role = user.Role
s.Status = user.Status
s.Exp = user.Exp s.Exp = user.Exp
// 滑动窗口续期 // 滑动窗口续期

View File

@ -14,6 +14,7 @@ type Session struct {
Username string // 用户名 Username string // 用户名
Avatar string // 头像 URL Avatar string // 头像 URL
Role string // 角色 Role string // 角色
Status string // 账号状态active/banned/deleted/locked
Exp int // 经验值(用于前端实时展示等级) Exp int // 经验值(用于前端实时展示等级)
RememberMe bool // 是否持久化(决定 cookie maxAge RememberMe bool // 是否持久化(决定 cookie maxAge
IP string // 登录 IP IP string // 登录 IP