Files
jiang13-bbs/backend/handler/auth.go
freefire 3a7bfe59df feat: 安全设置展示有效登录会话,支持剔除其它设备
按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 01:48:52 +08:00

355 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
var (
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
)
// setAuthCookies 设置认证 cookie:
// - access token(HttpOnly,15min)
// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
// - CSRF token(JS 可读,7天,双提交校验)
//
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
// 另有 CSRF 双提交 token 兜底。生产 HTTPS 下 cookie 名带 __Host- 前缀。
func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) {
csrfToken := service.GenerateCSRFToken()
http.SetCookie(c.Writer, &http.Cookie{
Name: service.CookieName,
Value: accessToken,
Path: "/",
MaxAge: accessCookieMaxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(c.Writer, &http.Cookie{
Name: service.RefreshCookieName,
Value: refreshToken,
Path: "/",
MaxAge: refreshCookieMaxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(c.Writer, &http.Cookie{
Name: service.CSRFCookieName,
Value: csrfToken,
Path: "/",
MaxAge: refreshCookieMaxAge,
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// clearAuthCookies 清除当前契约下的三枚认证 cookie(名称随 ConfigureCookieNames,Path=/)。
func clearAuthCookies(c *gin.Context, secure bool) {
cookies := []struct {
name string
httpOnly bool
}{
{service.CookieName, true},
{service.RefreshCookieName, true},
{service.CSRFCookieName, false},
}
for _, ck := range cookies {
http.SetCookie(c.Writer, &http.Cookie{
Name: ck.name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: ck.httpOnly,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
})
}
}
// RegisterRequest 注册请求
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=32"`
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password" binding:"required,min=6,max=64"`
}
// LoginRequest 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
func (h *Handlers) Register(c *gin.Context) {
if allow, err := h.Setting.AllowRegister(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册暂时不可用"})
return
} else if !allow {
c.JSON(http.StatusForbidden, gin.H{"error": "站点已关闭注册"})
return
}
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.Auth.Register(req.Username, req.Email, req.Password)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_ = h.Chat.EnsureDefaultMembership(user.ID)
// 自动登录:走与 Login 相同的 token 签发链路
accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
if err != nil {
// 签发失败不应让注册白做:仍返回成功,让前端引导去登录页
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"username": user.Username,
})
return
}
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
h.AdminUser.RecordLogin(loginUser.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": loginUser.ID,
"username": loginUser.Username,
"nickname": loginUser.Nickname,
"avatar": loginUser.Avatar,
"role": loginUser.Role,
},
})
}
// Login 用户登录:access + refresh token 写入 HttpOnly cookie,CSRF 写入可读 cookie
func (h *Handlers) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
if err != nil {
// 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0
h.AdminUser.RecordLogin(
h.Auth.GetUserIDByUsername(req.Username), req.Username,
c.ClientIP(), c.Request.UserAgent(), false,
)
// 封禁与凭据错误区分:前端可据此展示针对性提示
if errors.Is(err, service.ErrAccountBanned) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "account_banned"})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// 登录成功留痕(IP/UA/时间)
h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
// dev 模式不设 Secure,生产环境需 HTTPS
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
},
})
}
// Refresh 刷新 access token:验证 refresh token,轮转后签发新 access + refresh
func (h *Handlers) Refresh(c *gin.Context) {
refreshToken, err := c.Cookie(service.RefreshCookieName)
if err != nil || refreshToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
clearAuthCookies(c, !h.Cfg.DevMode)
if errors.Is(err, service.ErrAccountBanned) {
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
},
})
}
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
func (h *Handlers) Logout(c *gin.Context) {
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
h.Auth.RevokeRefreshToken(refreshToken)
}
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{"message": "已登出"})
}
// ChangePasswordRequest 修改密码请求
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6,max=64"`
}
// ChangePassword 修改密码:校验旧密码,更新新密码,清除所有登录态
func (h *Handlers) ChangePassword(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.Auth.ChangePassword(claims.ID, req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"})
}
// MyLoginDevices 当前用户仍有效的登录设备(仅本人;本机标当前在线)
func (h *Handlers) MyLoginDevices(c *gin.Context) {
claims := middleware.CurrentUser(c)
ua := strings.TrimSpace(c.GetHeader("X-J13-Client-UA"))
if ua == "" {
ua = c.Request.UserAgent()
}
ip := strings.TrimSpace(c.GetHeader("X-J13-Client-IP"))
if ip == "" {
ip = c.ClientIP()
}
familyID := claims.FamilyID
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil {
if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, ip, ua, claims.ID); fid != 0 {
familyID = fid
}
}
devices, err := h.Auth.ListLoginDevices(claims.ID, familyID, ip, ua)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取登录设备失败"})
return
}
c.JSON(http.StatusOK, gin.H{"devices": nonNilSlice(devices)})
}
// RevokeLoginDevice 剔除其它设备,该设备需重新输入密码登录
func (h *Handlers) RevokeLoginDevice(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "设备 ID 无效"})
return
}
familyID := claims.FamilyID
if refreshToken, e := c.Cookie(service.RefreshCookieName); e == nil {
if fid := h.Auth.LookupFamilyByRefreshPlain(refreshToken, claims.ID); fid != 0 {
familyID = fid
}
}
if err := h.Auth.RevokeLoginDevice(claims.ID, familyID, uint(id)); err != nil {
if errors.Is(err, service.ErrKickCurrentDevice) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrLoginDeviceNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "剔除设备失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// meUserBody 统一的用户信息响应体。boardIDs 为板块管理员被授权的板块,
// 供前端渲染管理菜单与前台审核按钮;其他角色为空数组
func meUserBody(user *model.User, boardIDs []uint) gin.H {
if boardIDs == nil {
boardIDs = []uint{}
}
return gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"signature": user.Signature,
"email": user.Email,
"role": user.Role,
"board_ids": boardIDs,
}
}
// Me 获取当前用户信息 + 未读通知数(合并为一次请求,供 SSR layout 直出)
func (h *Handlers) Me(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
resp := gin.H{"user": nil, "unread_count": 0, "chat_unread_count": 0}
// OptionalAuth 识别到封禁:HTTP 仍为 200(/me 不阻断页面),
// 但带 banned/code,客户端静默校正时据此强制下线并弹告知
if c.GetBool(middleware.AccountBannedKey) {
resp["banned"] = true
resp["code"] = "account_banned"
}
c.JSON(http.StatusOK, resp)
return
}
user, err := h.Auth.GetUserByID(claims.ID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0, "chat_unread_count": 0})
return
}
var unread int64
if n, err := h.Notification.UnreadCount(claims.ID); err == nil {
unread = n
}
chatUnread := int64(0)
if h.Setting != nil && h.Chat != nil {
if allow, err := h.Setting.AllowMessages(); err == nil && allow {
chatUnread = h.Chat.TotalUnread(claims.ID)
}
}
// 板块管理员带出授权板块,供前端菜单/按钮按板块范围渲染
var boardIDs []uint
if user.Role == model.RoleBoardAdmin {
boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID)
}
c.JSON(http.StatusOK, gin.H{
"user": meUserBody(user, boardIDs),
"unread_count": unread,
"chat_unread_count": chatUnread,
})
}