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 {
case common.ErrInvalidCred:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrUserBanned:
common.Error(c, http.StatusForbidden, "账号已被封禁")
case common.ErrUserLocked:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrMaintenanceMode:

View File

@ -5,6 +5,7 @@ import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"github.com/gin-gonic/gin"
@ -86,10 +87,14 @@ func (am *AuthMiddleware) Optional() gin.HandlerFunc {
// tryAutoCheckIn 尝试自动签到,并同步 session Exp 与 DB静默失败不影响主流程
// CheckIn 即使已签到也会返回 DB 中最新的 Exp这里始终与 session 比较,
// 确保 CompleteTask 等非签到路径的 Exp 变化也能同步到 session避免 NAV 显示旧等级
// 被封禁用户跳过自动签到
func (am *AuthMiddleware) tryAutoCheckIn(c *gin.Context, s *session.Session) {
if am.levelSvc == nil || s == nil {
return
}
if s.Status == model.StatusBanned {
return
}
_, newExp, _, _ := am.levelSvc.CheckIn(s.UserID)
// 只允许 Exp 只增不减DB 异常返回 0 或主从延迟读到旧值时不会回退
if newExp > s.Exp {
@ -109,6 +114,31 @@ func injectSessionContext(c *gin.Context, s *session.Session) {
c.Set("username", s.Username)
c.Set("avatar", s.Avatar)
c.Set("role", s.Role)
c.Set("status", s.Status)
c.Set("exp", s.Exp)
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.Use(d.authMdw.Required())
adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminAPI.Use(d.authMdw.BannedWriteGuard())
adminAPI.Use(middleware.CSRF(cfg))
{
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.Use(d.authMdw.Required())
siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner))
siteSettingsAPI.Use(d.authMdw.BannedWriteGuard())
siteSettingsAPI.Use(middleware.CSRF(cfg))
{
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.Use(d.authMdw.Required())
adminPostAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminPostAPI.Use(d.authMdw.BannedWriteGuard())
adminPostAPI.Use(middleware.CSRF(cfg))
{
adminPostAPI.POST("/:id/approve", d.adminPostCtrl.Approve)

View File

@ -12,6 +12,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 设置页 API需登录 ---
settingsAPI := r.Group("/api/settings")
settingsAPI.Use(d.authMdw.Required())
settingsAPI.Use(d.authMdw.BannedWriteGuard())
settingsAPI.Use(middleware.CSRF(cfg))
{
settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
@ -29,6 +30,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 消息中心 API需登录 ---
messagesAPI := r.Group("/api/messages")
messagesAPI.Use(d.authMdw.Required())
messagesAPI.Use(d.authMdw.BannedWriteGuard())
messagesAPI.Use(middleware.CSRF(cfg))
{
messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
@ -39,6 +41,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 积分/等级 API需登录 ---
levelAPI := r.Group("/api/level")
levelAPI.Use(d.authMdw.Required())
levelAPI.Use(d.authMdw.BannedWriteGuard())
levelAPI.Use(middleware.CSRF(cfg))
{
levelAPI.POST("/checkin", d.levelCtrl.CheckIn)
@ -64,6 +67,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 帖子 API需登录 ---
postsAPI := r.Group("/api/posts")
postsAPI.Use(d.authMdw.Required())
postsAPI.Use(d.authMdw.BannedWriteGuard())
postsAPI.Use(middleware.CSRF(cfg))
{
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
@ -73,6 +77,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 创作中心 API需登录 ---
studioAPI := r.Group("/api/studio")
studioAPI.Use(d.authMdw.Required())
studioAPI.Use(d.authMdw.BannedWriteGuard())
studioAPI.Use(middleware.CSRF(cfg))
{
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
}
// 封禁
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 {
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,
Avatar: user.Avatar,
Role: user.Role,
Status: user.Status,
Exp: user.Exp,
RememberMe: rememberMe,
IP: ip,
@ -54,7 +55,7 @@ func (m *Manager) Create(user *model.User, rememberMe bool, ip, userAgent string
}
// Validate 验证会话:读取 → 检查过期 → 检查用户状态 → 续期 → 返回
// 返回 nil 表示会话无效(不存在/过期/用户被封禁
// 若用户不存在则返回 nil被封禁/锁定用户仍返回 session由上层中间件决定可访问范围
func (m *Manager) Validate(sid string) (*Session, error) {
if sid == "" {
return nil, nil
@ -65,7 +66,7 @@ func (m *Manager) Validate(sid string) (*Session, error) {
return nil, nil
}
// 检查用户是否仍然有效(封禁/注销后拒绝已有会话)
// 检查用户是否仍然存在
user, err := m.userRepo.FindByIDForAuth(s.UserID)
if err != nil {
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
}
// 封禁用户直接拒绝
if user.Status == model.StatusBanned {
log.Printf("[SessionManager] Validate: user banned uid=%d", s.UserID)
m.store.Delete(sid)
return nil, nil
}
// 同步 DB 最新字段(头像/用户名/角色/经验值等可能已变更)
// 同步 DB 最新字段(头像/用户名/角色/状态/经验值等可能已变更)
s.Avatar = user.Avatar
s.Username = user.Username
s.Role = user.Role
s.Status = user.Status
s.Exp = user.Exp
// 滑动窗口续期

View File

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