Files
cao.wangrenbo 630d240dc0 feat(M1): 认证与用户管理 — 双通道登录、cookie 会话、用户 CRUD 与真实系统账号对接
认证:
- 图形验证码 GET /auth/captcha(内置 PNG 渲染,零第三方依赖)
- 外部用户 OTP 双通道:DB 存储(otp_codes)使邮件与 CLI 共用同一验证码/冷却/失败限速
- 管理员 bcrypt 登录 + 连续失败限速锁定;admin/forgot + admin/reset 邮件重置(SMTP 或日志)
- cookie 会话(HttpOnly/SameSite)、me/logout、admin/user 鉴权中间件

用户管理(admin):
- CRUD + disable/enable/extend/delete,对接 system 层真实 useradd/usermod/userdel/passwd
- system 层三执行模式:dry-run(默认,安全)/ direct(容器/测试用户)/ sudo(生产 sudoers 白名单)
- Exists 系统账号一致性检查;deploy/sudoers.example 白名单模板
- 关键操作接入 append-only 审计

其他:
- CLI user otp 改 DB store,与邮件通道真正对齐
- 容器镜像补 shadow(alpine 无 useradd);Makefile VERSION 0.2.0-m1
- 测试:auth/service 单测 + api httptest 集成 + 容器内真实系统账号端到端验证
2026-08-29 23:40:20 +08:00

196 lines
6.3 KiB
Go

package api
import (
"encoding/base64"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"ws_usernode/internal/auth"
"ws_usernode/internal/config"
"ws_usernode/internal/service"
)
// AuthHandler 认证接口:图形验证码、OTP 双通道登录、管理员登录、密码重置、会话。
type AuthHandler struct {
svc *service.AuthService
cfg *config.Config
}
// Captcha GET /auth/captcha —— 获取图形验证码(id + base64 PNG)。
func (h *AuthHandler) Captcha(c *gin.Context) {
id, png, err := h.svc.NewCaptcha()
if err != nil {
fail(c, http.StatusInternalServerError, "验证码生成失败")
return
}
ok(c, gin.H{
"captcha_id": id,
"image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(png),
})
}
// OTPSendRequest 外部用户请求 OTP。
type OTPSendRequest struct {
Username string `json:"username" binding:"required"` // 含或不含 ext_ 前缀
CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaCode string `json:"captcha_code" binding:"required"`
}
// OTPSend POST /auth/otp/send —— 图形验证码前置,生成 OTP 并发邮件(失败不阻断)。
func (h *AuthHandler) OTPSend(c *gin.Context) {
var req OTPSendRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
return
}
err := h.svc.UserOTPSend(c.Request.Context(), req.Username, req.CaptchaID, req.CaptchaCode, c.ClientIP())
if err != nil {
switch {
case errors.Is(err, service.ErrCaptchaFailed):
fail(c, http.StatusBadRequest, "图形验证码错误")
case errors.Is(err, service.ErrUserUnavailable):
fail(c, http.StatusNotFound, "用户不存在或不可用")
case errors.Is(err, auth.ErrCooldown):
fail(c, http.StatusTooManyRequests, "发送冷却中,请稍后重试")
default:
fail(c, http.StatusInternalServerError, err.Error())
}
return
}
ok(c, gin.H{"status": "sent"})
}
// OTPLoginRequest 外部用户 OTP 登录。
type OTPLoginRequest struct {
Username string `json:"username" binding:"required"`
Code string `json:"code" binding:"required"` // 6 位 OTP
}
// OTPLogin POST /auth/otp/login —— OTP 校验并建立 cookie 会话。
func (h *AuthHandler) OTPLogin(c *gin.Context) {
var req OTPLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
return
}
sid, err := h.svc.UserOTPLogin(c.Request.Context(), req.Username, req.Code, c.ClientIP(), c.Request.UserAgent())
if err != nil {
switch {
case errors.Is(err, service.ErrUserUnavailable):
fail(c, http.StatusNotFound, "用户不存在或不可用")
case errors.Is(err, auth.ErrInvalidCode):
fail(c, http.StatusUnauthorized, "验证码错误或已过期")
case errors.Is(err, auth.ErrTooManyFails):
fail(c, http.StatusTooManyRequests, "失败次数过多,请稍后再试")
default:
fail(c, http.StatusInternalServerError, err.Error())
}
return
}
setSessionCookie(c, sid, h.cfg.Server.SessionTTL, h.cfg.App.Env == "production")
ok(c, gin.H{"session": "created"})
}
// AdminLoginRequest 管理员登录。
type AdminLoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// AdminLogin POST /auth/admin/login —— 管理员用户名+口令登录(含失败限速)。
func (h *AuthHandler) AdminLogin(c *gin.Context) {
var req AdminLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
return
}
sid, err := h.svc.AdminLogin(c.Request.Context(), req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
if err != nil {
switch {
case errors.Is(err, service.ErrRateLimited):
fail(c, http.StatusTooManyRequests, "尝试次数过多,请稍后再试")
case errors.Is(err, service.ErrBadCredentials):
fail(c, http.StatusUnauthorized, "用户名或密码错误")
default:
fail(c, http.StatusInternalServerError, err.Error())
}
return
}
setSessionCookie(c, sid, h.cfg.Server.SessionTTL, h.cfg.App.Env == "production")
ok(c, gin.H{"session": "created"})
}
// AdminForgotRequest 管理员忘记密码。
type AdminForgotRequest struct {
Username string `json:"username" binding:"required"`
}
// AdminForgot POST /auth/admin/forgot —— 发送密码重置邮件(用户不存在也返回成功,防枚举)。
func (h *AuthHandler) AdminForgot(c *gin.Context) {
var req AdminForgotRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
return
}
if err := h.svc.AdminForgot(c.Request.Context(), req.Username, c.ClientIP()); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
ok(c, gin.H{"status": "sent"})
}
// AdminResetRequest 通过令牌重置密码。
type AdminResetRequest struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
// AdminReset POST /auth/admin/reset —— 校验令牌并重置密码。
func (h *AuthHandler) AdminReset(c *gin.Context) {
var req AdminResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
return
}
err := h.svc.AdminReset(c.Request.Context(), req.Token, req.NewPassword, c.ClientIP())
if err != nil {
switch {
case errors.Is(err, auth.ErrResetTokenInvalid):
fail(c, http.StatusBadRequest, "重置令牌无效或已过期")
case errors.Is(err, service.ErrWeakPassword):
fail(c, http.StatusBadRequest, err.Error())
default:
fail(c, http.StatusInternalServerError, err.Error())
}
return
}
ok(c, gin.H{"status": "reset"})
}
// Logout POST /auth/logout —— 登出(会话删除 + cookie 清除)。
func (h *AuthHandler) Logout(c *gin.Context) {
sess := sessionFrom(c)
if sess != nil {
_ = h.svc.Logout(c.Request.Context(), sess.ID)
}
setSessionCookie(c, "", 0, h.cfg.App.Env == "production")
ok(c, gin.H{"status": "logged_out"})
}
// Me GET /auth/me —— 当前会话主体信息。
func (h *AuthHandler) Me(c *gin.Context) {
sess := sessionFrom(c)
if sess == nil {
fail(c, http.StatusUnauthorized, "未登录")
return
}
info, err := h.svc.Me(c.Request.Context(), sess.ID)
if err != nil {
fail(c, http.StatusUnauthorized, "会话失效或已过期")
return
}
ok(c, info)
}