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: 会话无效")