认证: - 图形验证码 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 集成 + 容器内真实系统账号端到端验证
236 lines
8.5 KiB
Go
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: 会话无效")
|