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

236 lines
8.5 KiB
Go

package service
import (
"context"
"errors"
"log/slog"
"strings"
"time"
"gorm.io/gorm"
"ws_usernode/internal/auth"
"ws_usernode/internal/config"
"ws_usernode/internal/mail"
"ws_usernode/internal/model"
)
// 认证服务错误。
var (
ErrRateLimited = errors.New("service: 尝试次数过多,请稍后再试")
ErrCaptchaFailed = errors.New("service: 图形验证码错误")
ErrUserUnavailable = errors.New("service: 用户不存在或不可用")
)
// AuthService 认证服务:管理员/外部用户登录、会话、图形验证码、OTP 双通道、
// 密码重置。持有各存储与邮件发送器,作为 api 层与底层存储之间的桥。
type AuthService struct {
db *gorm.DB
cfg *config.Config
otps auth.OTPStore
captchas auth.CaptchaStore
sessions auth.SessionStore
resets auth.ResetTokenStore
limiter *auth.RateLimiter
mailer mail.Mailer
users *UserService
admins *AdminService
audit *AuditService
log *slog.Logger
}
// NewAuthService 组装认证服务。
func NewAuthService(db *gorm.DB, cfg *config.Config, otps auth.OTPStore, captchas auth.CaptchaStore,
sessions auth.SessionStore, resets auth.ResetTokenStore, limiter *auth.RateLimiter,
mailer mail.Mailer, users *UserService, admins *AdminService, audit *AuditService, log *slog.Logger) *AuthService {
return &AuthService{
db: db, cfg: cfg, otps: otps, captchas: captchas, sessions: sessions,
resets: resets, limiter: limiter, mailer: mailer, users: users,
admins: admins, audit: audit, log: log,
}
}
// NewCaptcha 生成图形验证码并渲染 PNG 图像。
func (s *AuthService) NewCaptcha() (id string, png []byte, err error) {
cap, err := s.captchas.New()
if err != nil {
return "", nil, err
}
png, err = auth.RenderCaptchaPNG(cap.Text)
if err != nil {
return "", nil, err
}
return cap.ID, png, nil
}
// createSession 建立 cookie 会话(DB 存储)。
func (s *AuthService) createSession(ctx context.Context, userType string, refID uint, ip, userAgent string) (string, error) {
return s.sessions.Create(ctx, userType, refID, s.cfg.Server.SessionTTL, ip, userAgent)
}
// AdminLogin 管理员用户名+口令登录,返回会话 ID。
// 连续失败达到阈值(config auth.max_login_failures)后锁定 lock_duration。
func (s *AuthService) AdminLogin(ctx context.Context, username, password, ip, userAgent string) (string, error) {
key := "admin-login:" + strings.TrimSpace(username)
if !s.limiter.Allow(key) {
_ = s.audit.Record(ctx, 0, username, "admin.login", "admin", "", map[string]any{"locked": true}, ip, model.ResultFailed)
return "", ErrRateLimited
}
adm, err := s.admins.Login(ctx, username, password)
if err != nil {
s.limiter.RecordFailure(key)
_ = s.audit.Record(ctx, 0, username, "admin.login", "admin", "", nil, ip, model.ResultFailed)
return "", ErrBadCredentials
}
s.limiter.Reset(key)
sid, err := s.createSession(ctx, auth.SessionUserAdmin, adm.ID, ip, userAgent)
if err != nil {
return "", err
}
_ = s.audit.Record(ctx, adm.ID, adm.Username, "admin.login", "admin", "", nil, ip, model.ResultSuccess)
return sid, nil
}
// Logout 注销会话(管理员或外部用户通用)。
func (s *AuthService) Logout(ctx context.Context, sessionID string) error {
return s.sessions.Delete(ctx, sessionID)
}
// AdminForgot 发送密码重置邮件(无 SMTP 时退化为日志输出)。
// 用户不存在时也返回成功,避免账号枚举。
func (s *AuthService) AdminForgot(ctx context.Context, username, ip string) error {
username = strings.TrimSpace(username)
adm, err := s.admins.GetByUsername(ctx, username)
if err != nil {
s.log.Warn("admin forgot: user not found (not revealed)", "username", username)
return nil
}
token, err := s.resets.Create(ctx, adm.ID, 30*time.Minute, ip)
if err != nil {
return err
}
link := strings.TrimRight(s.cfg.App.BaseURL, "/") + "/reset?token=" + token
body := "您正在重置管理员密码。请在 30 分钟内打开以下链接完成重置:\n\n" + link +
"\n\n如非本人操作请忽略本邮件。也可由管理员通过 CLI `usernode admin reset-password` 重置。"
if err := s.mailer.Send(ctx, adm.Email, "重置密码", body); err != nil {
s.log.Warn("admin forgot: mail send failed", "err", err)
}
_ = s.audit.Record(ctx, adm.ID, adm.Username, "admin.forgot", "admin", "", nil, ip, model.ResultSuccess)
return nil
}
// AdminReset 通过令牌重置管理员密码,并使该管理员既有会话全部失效。
func (s *AuthService) AdminReset(ctx context.Context, token, newPassword string, ip string) error {
adminID, err := s.resets.Consume(ctx, token)
if err != nil {
return err
}
adm, err := s.admins.GetByID(ctx, adminID)
if err != nil {
return err
}
if err := validatePassword(newPassword); err != nil {
return err
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
return err
}
if err := s.db.WithContext(ctx).Model(&model.AdminUser{}).Where("id = ?", adm.ID).Update("password_hash", hash).Error; err != nil {
return err
}
// 重置后吊销该管理员全部会话
_ = s.db.WithContext(ctx).Where("user_type = ? AND ref_id = ?", auth.SessionUserAdmin, adm.ID).Delete(&model.Session{}).Error
_ = s.audit.Record(ctx, adm.ID, adm.Username, "admin.reset", "admin", "", nil, ip, model.ResultSuccess)
return nil
}
// UserOTPSend 外部用户申请 OTP:图形验证码前置,生成验证码并邮件发送。
// 邮件失败不阻断(CLI 通道兜底)。冷却/失败限速与 CLI 通道共享同一存储。
func (s *AuthService) UserOTPSend(ctx context.Context, username, captchaID, captchaAnswer, ip string) error {
if !s.captchas.Verify(captchaID, captchaAnswer) {
return ErrCaptchaFailed
}
u, err := s.users.GetByUsername(ctx, username)
if err != nil {
return ErrUserUnavailable
}
if u.Status != model.UserStatusActive {
return ErrUserUnavailable
}
code, err := s.otps.Send(ctx, u.Username, s.cfg.Policy.OTPTTL, s.cfg.Policy.OTPCooldown)
if err != nil {
return err
}
body := "您的登录验证码是:" + code +
"\n有效期 " + s.cfg.Policy.OTPTTL.String() + ",请勿向他人泄露。\n" +
"如未收到邮件,可通过 CLI 子命令 `usernode user otp --username " + u.Username + "` 获取同一验证码。"
if err := s.mailer.Send(ctx, u.Email, "登录验证码", body); err != nil {
// OTP 邮件失败不阻断登录(PLAN F5);CLI 通道仍可获取同一验证码
s.log.Warn("otp mail send failed, cli channel remains available", "username", u.Username, "err", err)
}
_ = s.audit.Record(ctx, u.ID, u.Username, "user.otp.send", "user", "", nil, ip, model.ResultSuccess)
return nil
}
// UserOTPLogin 外部用户 OTP 登录,返回会话 ID。
func (s *AuthService) UserOTPLogin(ctx context.Context, username, code, ip, userAgent string) (string, error) {
u, err := s.users.GetByUsername(ctx, username)
if err != nil {
return "", ErrUserUnavailable
}
if u.Status != model.UserStatusActive {
return "", ErrUserUnavailable
}
ok, err := s.otps.Verify(ctx, u.Username, code)
if err != nil {
_ = s.audit.Record(ctx, u.ID, u.Username, "user.otp.login", "user", "", map[string]any{"err": err.Error()}, ip, model.ResultFailed)
return "", err
}
if !ok {
return "", auth.ErrInvalidCode
}
now := time.Now()
_ = s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", u.ID).Update("last_login_at", &now).Error
sid, err := s.createSession(ctx, auth.SessionUserUser, u.ID, ip, userAgent)
if err != nil {
return "", err
}
_ = s.audit.Record(ctx, u.ID, u.Username, "user.otp.login", "user", "", nil, ip, model.ResultSuccess)
return sid, nil
}
// MeInfo 当前会话对应的主体信息。
type MeInfo struct {
UserType string `json:"user_type"`
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
}
// Me 根据会话 ID 返回当前登录主体。
func (s *AuthService) Me(ctx context.Context, sessionID string) (*MeInfo, error) {
sess, err := s.sessions.Get(ctx, sessionID)
if err != nil {
return nil, err
}
switch sess.UserType {
case auth.SessionUserAdmin:
adm, err := s.admins.GetByID(ctx, sess.RefID)
if err != nil {
return nil, err
}
return &MeInfo{UserType: sess.UserType, ID: adm.ID, Username: adm.Username, Email: adm.Email}, nil
case auth.SessionUserUser:
u, err := s.users.GetByID(ctx, sess.RefID)
if err != nil {
return nil, err
}
return &MeInfo{UserType: sess.UserType, ID: u.ID, Username: u.Username, Email: u.Email}, nil
}
return nil, ErrSessionInvalid
}
// ErrSessionInvalid 表示会话类型未知。
var ErrSessionInvalid = errors.New("service: 会话无效")