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 集成 + 容器内真实系统账号端到端验证
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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: 会话无效")
|
||||
Reference in New Issue
Block a user