package service import ( "context" "crypto/sha256" "encoding/hex" "errors" "strconv" "strings" "time" "backend/internal/model" "backend/internal/repo" "gorm.io/gorm" ) var ErrAuthFailed = errors.New("auth failed") type AuthService struct { users *repo.UserRepository settings *repo.SiteSettingRepository sessions *SessionService codes *EmailCodeService smtp *SMTPService loginGuard *LoginGuard } type AuthSettings struct { Open bool EmailCode bool AllowPasswordReset bool AllowedDomains []string } func NewAuthService( users *repo.UserRepository, settings *repo.SiteSettingRepository, sessions *SessionService, codes *EmailCodeService, smtp *SMTPService, ) *AuthService { return &AuthService{ users: users, settings: settings, sessions: sessions, codes: codes, smtp: smtp, loginGuard: NewLoginGuard(codes.Redis()), } } func (s *AuthService) IsAuthorizedForPrivateImage(ctx context.Context, sessionCookie, owner string) (bool, error) { // Private images are viewable ONLY via a logged-in session cookie (no Bearer // token / API key). A regular user may view only their OWN images; an admin // may view anyone's. `owner` is the /images//... path segment. if sessionCookie == "" { return false, nil } payload, err := s.sessions.Validate(ctx, sessionCookie) if err != nil { return false, err } if payload == nil { return false, nil } user, err := s.users.GetByID(ctx, payload.UserID) if err != nil { if err == gorm.ErrRecordNotFound { return false, nil } return false, err } if user.Role == "admin" { return true, nil } return ownsImageDir(user, owner), nil } // ownsImageDir reports whether `owner` (the /images//... directory) is one // of the names this user's outputs are stored under. Mirrors the candidates // V1Service.userDir picks from: sanitized name → sanitized email-local → id. func ownsImageDir(user *model.User, owner string) bool { owner = strings.TrimSpace(owner) if owner == "" || user == nil { return false } if owner == user.ID { return true } if d := sanitizeOwnerName(user.Name); d != "" && d == owner { return true } if d := sanitizeOwnerName(strings.Split(user.Email, "@")[0]); d != "" && d == owner { return true } return false } func (s *AuthService) CurrentUserFromBearer(ctx context.Context, authHeader string) (*model.User, *SessionPayload, error) { token := ParseBearer(authHeader) return s.currentUserFromToken(ctx, token) } func (s *AuthService) CurrentUserFromRequest(ctx context.Context, authHeader, cookieToken string) (*model.User, *SessionPayload, error) { if user, session, err := s.CurrentUserFromBearer(ctx, authHeader); err != nil || user != nil || session != nil { return user, session, err } return s.currentUserFromToken(ctx, cookieToken) } func (s *AuthService) CurrentUserFromToken(ctx context.Context, token string) (*model.User, *SessionPayload, error) { return s.currentUserFromToken(ctx, token) } func (s *AuthService) currentUserFromToken(ctx context.Context, token string) (*model.User, *SessionPayload, error) { if token == "" { return nil, nil, nil } payload, err := s.sessions.Validate(ctx, token) if err != nil { return nil, nil, err } if payload == nil { return nil, nil, nil } user, err := s.users.GetByID(ctx, payload.UserID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, nil, nil } return nil, nil, err } if user.Status != "active" { return nil, nil, nil } return user, payload, nil } func (s *AuthService) Login(ctx context.Context, identifier, password, ip string) (*model.User, string, *SessionPayload, error) { normalizedIdentifier, err := ValidateLoginIdentifier(identifier) if err != nil { return nil, "", nil, err } if strings.TrimSpace(password) == "" { return nil, "", nil, errors.New("密码不能为空") } // Exponential-backoff lockout per (ip, account) + per-ip spray (Python // api/auth.py:226-237 via core/login_guard.py). if err := s.loginGuard.Check(ctx, ip, normalizedIdentifier); err != nil { return nil, "", nil, err } user, err := s.users.GetByIdentifier(ctx, normalizedIdentifier) if err != nil { if err == gorm.ErrRecordNotFound { if rerr := s.loginGuard.RecordFailure(ctx, ip, normalizedIdentifier); rerr != nil { return nil, "", nil, rerr } return nil, "", nil, ErrAuthFailed } return nil, "", nil, err } if user.Status != "active" || !VerifyPassword(password, user.PasswordHash) { if rerr := s.loginGuard.RecordFailure(ctx, ip, normalizedIdentifier); rerr != nil { return nil, "", nil, rerr } return nil, "", nil, ErrAuthFailed } if err := s.loginGuard.RecordSuccess(ctx, ip, normalizedIdentifier); err != nil { return nil, "", nil, err } if err := s.users.TouchLogin(ctx, user.ID, ip); err != nil { return nil, "", nil, err } token, payload, err := s.sessions.Create(ctx, user.ID) if err != nil { return nil, "", nil, err } return user, token, payload, nil } func (s *AuthService) SendCode(ctx context.Context, email, purpose string) error { cfg, err := s.loadAuthSettings(ctx) if err != nil { return err } if !cfg.EmailCode { return errors.New("未开启邮箱验证码") } normalizedEmail, err := ValidateEmail(email) if err != nil { return err } purpose = strings.ToLower(strings.TrimSpace(purpose)) switch purpose { case "register", "reset": default: return errors.New("验证码用途不正确") } if purpose == "register" && !EmailDomainAllowed(normalizedEmail, cfg.AllowedDomains) { return errors.New("该邮箱后缀不允许注册") } code, err := s.codes.Issue(ctx, normalizedEmail, purpose) if err != nil { return err } return s.smtp.SendCode(ctx, s.loadSMTPSettings(ctx), normalizedEmail, code, purpose) } func (s *AuthService) Register(ctx context.Context, email, username, password, inviteCode, emailCode, ip string) (*model.User, string, *SessionPayload, error) { normalizedEmail, err := ValidateEmail(email) if err != nil { return nil, "", nil, err } normalizedUsername, err := ValidateUsername(username) if err != nil { return nil, "", nil, err } if err := ValidatePassword(password); err != nil { return nil, "", nil, err } settings, err := s.loadAuthSettings(ctx) if err != nil { return nil, "", nil, err } hasAdmin, err := s.users.HasAdmin(ctx) if err != nil { return nil, "", nil, err } // The very first account ever bootstraps the admin and skips the open // toggle, the email-domain whitelist, and the email-code gate (Python // api/auth.py:195-204). All three are only enforced once an admin exists. if hasAdmin && !settings.Open { return nil, "", nil, errors.New("当前未开放注册") } if hasAdmin && !EmailDomainAllowed(normalizedEmail, settings.AllowedDomains) { return nil, "", nil, errors.New("该邮箱后缀不允许注册") } if hasAdmin && settings.EmailCode { ok, err := s.codes.Verify(ctx, normalizedEmail, "register", emailCode) if err != nil { return nil, "", nil, err } if !ok { return nil, "", nil, errors.New("邮箱验证码错误或已过期") } } exists, err := s.users.ExistsEmail(ctx, normalizedEmail, "") if err != nil { return nil, "", nil, err } if exists { return nil, "", nil, errors.New("邮箱已存在") } exists, err = s.users.ExistsName(ctx, normalizedUsername, "") if err != nil { return nil, "", nil, err } if exists { return nil, "", nil, errors.New("用户名已存在") } passwordHash, err := HashPassword(password) if err != nil { return nil, "", nil, err } role := "user" if !hasAdmin { role = "admin" } var invitedBy *string if strings.TrimSpace(inviteCode) != "" { inviter, err := s.users.GetByInviteCode(ctx, inviteCode) if err == nil { invitedBy = &inviter.ID } } now := time.Now() user := &model.User{ ID: "u-" + randomUpper(10), Email: normalizedEmail, Name: normalizedUsername, PasswordHash: passwordHash, Role: role, Status: "active", InviteCode: randomInviteCode(), InvitedBy: invitedBy, CreatedAt: now, UpdatedAt: now, } if err := s.users.Create(ctx, user); err != nil { return nil, "", nil, err } if err := s.users.TouchLogin(ctx, user.ID, ip); err != nil { return nil, "", nil, err } token, payload, err := s.sessions.Create(ctx, user.ID) if err != nil { return nil, "", nil, err } created, err := s.users.GetByID(ctx, user.ID) if err != nil { return nil, "", nil, err } return created, token, payload, nil } func (s *AuthService) ResetPassword(ctx context.Context, email, password, emailCode, ip string) error { settings, err := s.loadAuthSettings(ctx) if err != nil { return err } if !settings.EmailCode || !settings.AllowPasswordReset { return errors.New("未开放找回密码") } normalizedEmail, err := ValidateEmail(email) if err != nil { return err } if err := ValidatePassword(password); err != nil { return err } // Rate-limit reset attempts per IP+email so the 6-digit code can't be ground // down even with the single-use + wrong-guess cap (Python api/auth.py:257-268). guardID := "reset:" + normalizedEmail if err := s.loginGuard.Check(ctx, ip, guardID); err != nil { return err } ok, err := s.codes.Verify(ctx, normalizedEmail, "reset", emailCode) if err != nil { return err } if !ok { if rerr := s.loginGuard.RecordFailure(ctx, ip, guardID); rerr != nil { return rerr } return errors.New("邮箱验证码错误或已过期") } if err := s.loginGuard.RecordSuccess(ctx, ip, guardID); err != nil { return err } passwordHash, err := HashPassword(password) if err != nil { return err } _, err = s.users.SetPasswordByEmail(ctx, normalizedEmail, passwordHash) return err } func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error { if strings.TrimSpace(currentPassword) == "" { return errors.New("当前密码不能为空") } if err := ValidatePassword(newPassword); err != nil { return err } user, err := s.users.GetByID(ctx, userID) if err != nil { return err } if !VerifyPassword(currentPassword, user.PasswordHash) { return errors.New("当前密码错误") } passwordHash, err := HashPassword(newPassword) if err != nil { return err } _, err = s.users.Update(ctx, userID, map[string]any{ "password_hash": passwordHash, }) return err } func (s *AuthService) Logout(ctx context.Context, token string) error { return s.sessions.Destroy(ctx, token) } func (s *AuthService) AuthConfig(ctx context.Context) (map[string]any, error) { hasAdmin, err := s.users.HasAdmin(ctx) if err != nil { return nil, err } settings, err := s.loadAuthSettings(ctx) if err != nil { return nil, err } credits, err := s.loadCreditSettings(ctx) if err != nil { return nil, err } return map[string]any{ "open": settings.Open, "email_code": settings.EmailCode, "allow_password_reset": settings.AllowPasswordReset, "allowed_email_domains": settings.AllowedDomains, "has_admin": hasAdmin, "checkin_enabled": credits.CheckinEnabled, "checkin_reward": credits.CheckinReward, "invite_enabled": credits.InviteEnabled, "invite_reward": credits.InviteReward, "server_time": time.Now().Unix(), }, nil } func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[string]any, error) { if user == nil { return nil, nil } credits, err := s.loadCreditSettings(ctx) if err != nil { return nil, err } stats, err := s.users.InviteStats(ctx, user.ID, credits.InviteReward) if err != nil { return nil, err } return map[string]any{ "id": user.ID, "email": user.Email, "name": user.Name, "role": user.Role, "status": user.Status, "credits": user.Credits, "checkin_last": user.CheckinLast, "checkin_streak": user.CheckinStreak, "checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"), "invite_code": user.InviteCode, "invite_count": stats.InviteCount, "invite_earned": stats.InviteEarned, }, nil } func (s *AuthService) Checkin(ctx context.Context, userID string) (*repo.CheckinResult, error) { credits, err := s.loadCreditSettings(ctx) if err != nil { return nil, err } if !credits.CheckinEnabled { return nil, errors.New("签到功能未开启") } return s.users.DailyCheckin(ctx, userID, credits.CheckinReward) } func (s *AuthService) InviteList(ctx context.Context, userID string) ([]repo.InviteRecord, error) { credits, err := s.loadCreditSettings(ctx) if err != nil { return nil, err } return s.users.InviteList(ctx, userID, credits.InviteReward) } func ParseBearer(header string) string { if header == "" { return "" } lower := strings.ToLower(header) if !strings.HasPrefix(lower, "bearer ") { return "" } return strings.TrimSpace(header[7:]) } func HashAPIKey(plaintext string) string { sum := sha256.Sum256([]byte(plaintext)) return "sha256:" + hex.EncodeToString(sum[:]) } func (s *AuthService) loadAuthSettings(ctx context.Context) (*AuthSettings, error) { openRaw, err := s.settings.GetValue(ctx, "auth.open") if err != nil { return nil, err } emailCodeRaw, err := s.settings.GetValue(ctx, "auth.email_code") if err != nil { return nil, err } resetRaw, err := s.settings.GetValue(ctx, "auth.allow_password_reset") if err != nil { return nil, err } domainsRaw, err := s.settings.GetValue(ctx, "auth.allowed_email_domains") if err != nil { return nil, err } return &AuthSettings{ Open: parseBoolSetting(openRaw, true), EmailCode: parseBoolSetting(emailCodeRaw, false), AllowPasswordReset: parseBoolSetting(resetRaw, false), AllowedDomains: parseCSVSetting(domainsRaw), }, nil } func (s *AuthService) loadSMTPSettings(ctx context.Context) SMTPConfig { host, _ := s.settings.GetValue(ctx, "smtp.host") portRaw, _ := s.settings.GetValue(ctx, "smtp.port") username, _ := s.settings.GetValue(ctx, "smtp.username") password, _ := s.settings.GetValue(ctx, "smtp.password") fromAddr, _ := s.settings.GetValue(ctx, "smtp.from_addr") useTLSRaw, _ := s.settings.GetValue(ctx, "smtp.use_tls") port, _ := strconv.Atoi(strings.TrimSpace(portRaw)) if port <= 0 { port = 587 } // Fall back to username when from_addr is unset (Python core/email_codes.py:92). from := strings.TrimSpace(fromAddr) if from == "" { from = strings.TrimSpace(username) } return SMTPConfig{ Host: strings.TrimSpace(host), Port: port, Username: strings.TrimSpace(username), Password: password, FromAddr: from, // use_tls defaults to true to match Python (core/email_codes.py:93). UseTLS: parseBoolSetting(useTLSRaw, true), } } func parseBoolSetting(v string, fallback bool) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: return fallback } } func parseCSVSetting(v string) []string { if strings.TrimSpace(v) == "" { return []string{} } return ValidateAllowedEmailDomains(strings.Split(v, ",")) } // InviteReward returns the admin-configured 积分 awarded per completed invite // (falls back to 3). Exposed so the invite page shows the real number. func (s *AuthService) InviteReward(ctx context.Context) int { cs, err := s.loadCreditSettings(ctx) if err != nil { return 3 } return cs.InviteReward } func (s *AuthService) loadCreditSettings(ctx context.Context) (*CreditSettings, error) { checkinEnabledRaw, err := s.settings.GetValue(ctx, "credits.checkin_enabled") if err != nil { return nil, err } checkinRewardRaw, err := s.settings.GetValue(ctx, "credits.checkin_reward") if err != nil { return nil, err } inviteEnabledRaw, err := s.settings.GetValue(ctx, "credits.invite_enabled") if err != nil { return nil, err } inviteRewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward") if err != nil { return nil, err } return &CreditSettings{ CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true), CheckinReward: parseIntSetting(checkinRewardRaw, 3), InviteEnabled: parseBoolSetting(inviteEnabledRaw, true), InviteReward: parseIntSetting(inviteRewardRaw, 3), }, nil }