// Package service orchestrates the auth flows on top of the repositories. // // The handler layer is intentionally dumb: it parses the request, calls one // AuthService method, and renders the result. All branching (already exists? // wrong password? expired token?) lives here, behind a small set of named // error sentinels. package service import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" "time" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/auth" "github.com/prabhupadasays/spsays-bff/pkg/mailer" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" ) // Sentinel errors. Handlers map these to HTTP status codes. var ( ErrInvalidCredentials = errors.New("invalid email or password") ErrEmailAlreadyTaken = errors.New("email already registered") ErrUserNotFound = errors.New("user not found") ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired") ErrEmailAlreadyVerified = errors.New("email already verified") ErrWeakPassword = errors.New("password must be at least 8 characters") ErrHandleTaken = errors.New("handle already in use") ) // AuthService bundles every flow that mutates auth state. type AuthService struct { users *repository.UserRepo tokens *repository.AuthTokenRepo oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows jwt *auth.Issuer mail mailer.Mailer logger *zap.Logger frontend string // FrontendURL — used to build reset / verify links // TTLs — surfaced as fields so tests can shrink them. PasswordResetTTL time.Duration EmailVerifyTTL time.Duration } // NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if // the OAuth flows aren't wired (handler then returns 503). func NewAuthService( users *repository.UserRepo, tokens *repository.AuthTokenRepo, oauthRepo *repository.OAuthAccountRepo, jwt *auth.Issuer, mail mailer.Mailer, logger *zap.Logger, frontendURL string, ) *AuthService { return &AuthService{ users: users, tokens: tokens, oauth: oauthRepo, jwt: jwt, mail: mail, logger: logger, frontend: strings.TrimRight(frontendURL, "/"), PasswordResetTTL: 1 * time.Hour, EmailVerifyTTL: 24 * time.Hour, } } // AuthResult is the value handlers return on signup/login. Token is the raw // JWT the handler will plant in the response cookie; the User is the public // profile to ship in the response body. type AuthResult struct { User *model.User Token string Expires time.Time } // SignUp creates a new user, issues a session JWT, and fires off a // verification email (best-effort — failure to send does not block signup, // since the user can hit resend later). func (s *AuthService) SignUp(ctx context.Context, email, password, name string) (*AuthResult, error) { if len(password) < 8 { return nil, ErrWeakPassword } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("hash password: %w", err) } // Generate a handle and retry up to 8 times if it collides with an // existing user. The slug seed is derived from the email's local-part, // which is the closest thing to a free-form identifier the user gave us. base := slugForHandle(email) var u *model.User for attempt := 0; attempt < 8; attempt++ { handle := base if attempt > 0 { handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix()) } u, err = s.users.Create(ctx, email, string(hash), strings.TrimSpace(name), handle) if err == nil { break } if errors.Is(err, repository.ErrEmailTaken) { return nil, ErrEmailAlreadyTaken } if !errors.Is(err, repository.ErrHandleTaken) { return nil, err } } if err != nil { return nil, fmt.Errorf("create user: %w", err) } if err := s.sendVerificationEmail(ctx, u); err != nil { // Log and keep going — signup itself succeeded. s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err)) } return s.issueSession(u) } // LogIn authenticates a user by email + password. The error returned for an // unknown email is intentionally identical to a wrong password — never leak // which one was wrong. func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) { u, err := s.users.FindByEmail(ctx, email) if err != nil { if errors.Is(err, repository.ErrUserNotFound) { return nil, ErrInvalidCredentials } return nil, err } // OAuth-only accounts have no password hash. Treat them as a credential // mismatch so we don't reveal that an OAuth user exists for this email. if u.PasswordHash == nil { return nil, ErrInvalidCredentials } if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil { return nil, ErrInvalidCredentials } return s.issueSession(u) } // OAuthProfile is the provider-agnostic profile shape consumed by // SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service // layer doesn't import the OAuth package (which would create a layering // cycle once handlers compose both). type OAuthProfile struct { Provider string ProviderUserID string Email string EmailVerified bool Name string AvatarURL string } // ErrOAuthEmailMissing is returned by SignInWithOAuth when the provider did // not give us an email and the account has no linkage we could match on. // Without an email we can't safely create a new user (since later sign-ins // via email/password would have no way to converge with this account). var ErrOAuthEmailMissing = errors.New("oauth provider did not return an email") // ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at // startup). Handler maps to 503. var ErrOAuthUnavailable = errors.New("oauth service unavailable") // SignInWithOAuth turns a provider profile into a session. Three-step lookup: // // 1. Existing linkage on (provider, provider_user_id) → log that user in. // 2. Existing user with same email → link this provider to them, log in. // 3. Otherwise create a new user (no password, email_verified) and link. // // Every successful OAuth round-trip is treated as proof the user controls // the email address — Google + Facebook both verify email ownership before // returning it to us, so we always mark the email as verified on sign-in // regardless of the provider's `email_verified` flag. // // In every case the resulting user gets a fresh JWT just like LogIn. func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) { if s.oauth == nil { return nil, ErrOAuthUnavailable } // 1. Known linkage. if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil { u, err := s.users.FindByID(ctx, a.UserID) if err != nil { return nil, err } // Refresh denormalised fields on the linkage row (display name / // avatar may have changed at the provider). _, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID, optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)) s.ensureEmailVerified(ctx, u) return s.issueSession(u) } else if !errors.Is(err, repository.ErrOAuthAccountNotFound) { return nil, err } // No linkage yet — we need at least an email to safely converge with an // existing account or create a new one. if prof.Email == "" { return nil, ErrOAuthEmailMissing } // 2. Existing user with the same email → link. if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil { if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID, optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil { return nil, err } s.ensureEmailVerified(ctx, u) return s.issueSession(u) } else if !errors.Is(err, repository.ErrUserNotFound) { return nil, err } // 3. Brand new user. Reuse the handle-collision retry loop. displayName := prof.Name if displayName == "" { displayName = strings.SplitN(prof.Email, "@", 2)[0] } base := slugForHandle(prof.Email) var u *model.User var err error for attempt := 0; attempt < 8; attempt++ { handle := base if attempt > 0 { handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix()) } // Always create as verified — successful OAuth = email ownership proof. u, err = s.users.CreateOAuth(ctx, prof.Email, displayName, handle, optStr(prof.AvatarURL), true) if err == nil { break } if errors.Is(err, repository.ErrEmailTaken) { // Race: someone else just claimed this email. Fall back to step 2. if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil { _, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID, optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)) s.ensureEmailVerified(ctx, u2) return s.issueSession(u2) } return nil, err } if !errors.Is(err, repository.ErrHandleTaken) { return nil, err } } if err != nil { return nil, fmt.Errorf("create oauth user: %w", err) } if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID, optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil { return nil, err } return s.issueSession(u) } // optStr returns nil for empty input, &s otherwise — used to populate // nullable text columns from possibly-empty profile fields. func optStr(s string) *string { if s == "" { return nil } return &s } // ensureEmailVerified flips email_verified_at to now() on the given user // if it's not already set. Called from every SignInWithOAuth branch so a // successful OAuth round-trip immediately satisfies email verification // for an existing-but-unverified account. Mutates u in place so callers // see the updated timestamp without re-fetching. func (s *AuthService) ensureEmailVerified(ctx context.Context, u *model.User) { if u == nil || u.EmailVerifiedAt != nil { return } if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil { s.logger.Warn("mark email verified after oauth", zap.String("user", u.ID), zap.Error(err)) return } now := time.Now() u.EmailVerifiedAt = &now } // Me looks up a user by id and re-verifies the JWT's token_version against // the stored value. Mismatch means the user has logged-out-everywhere or // changed password since this JWT was issued → reject. func (s *AuthService) Me(ctx context.Context, userID string, tokenVersion int) (*model.User, error) { u, err := s.users.FindByID(ctx, userID) if err != nil { return nil, ErrUserNotFound } if u.TokenVersion != tokenVersion { return nil, auth.ErrInvalidToken } return u, nil } // RequestPasswordReset always returns nil to callers — we don't want to // reveal whether the email is registered. If the email exists we mint a // token and email a link; otherwise we silently no-op. func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error { u, err := s.users.FindByEmail(ctx, email) if err != nil { if errors.Is(err, repository.ErrUserNotFound) { return nil // do not reveal } return err } // Invalidate any previous reset tokens for this user. _ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposePasswordReset) raw, hash, err := newToken() if err != nil { return err } if _, err := s.tokens.Create(ctx, u.ID, model.PurposePasswordReset, hash, s.PasswordResetTTL); err != nil { return err } link := fmt.Sprintf("%s/reset-password?token=%s", s.frontend, raw) body := passwordResetEmail(u.Name, link) if err := s.mail.Send(u.Email, "Reset your Prabhupāda Says password", body); err != nil { s.logger.Warn("password reset email failed", zap.String("user", u.ID), zap.Error(err)) } return nil } // ResetPassword consumes a reset token and sets a new password. // On success the user's token_version is bumped, invalidating every // outstanding JWT (so a stolen token can't be used after the legit owner // changes their password). func (s *AuthService) ResetPassword(ctx context.Context, rawToken, newPassword string) error { if len(newPassword) < 8 { return ErrWeakPassword } hash := hashToken(rawToken) t, err := s.tokens.FindValid(ctx, hash, model.PurposePasswordReset) if err != nil { return ErrInvalidOrExpiredLink } bcryptHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash password: %w", err) } if err := s.users.UpdatePassword(ctx, t.UserID, string(bcryptHash)); err != nil { return err } if err := s.tokens.Consume(ctx, t.ID); err != nil { s.logger.Warn("consume reset token", zap.String("token_id", t.ID), zap.Error(err)) } return nil } // VerifyEmail consumes an email-verification token and flips the user's // email_verified_at timestamp. func (s *AuthService) VerifyEmail(ctx context.Context, rawToken string) error { hash := hashToken(rawToken) t, err := s.tokens.FindValid(ctx, hash, model.PurposeEmailVerification) if err != nil { return ErrInvalidOrExpiredLink } if err := s.users.MarkEmailVerified(ctx, t.UserID); err != nil { return err } if err := s.tokens.Consume(ctx, t.ID); err != nil { s.logger.Warn("consume verify token", zap.String("token_id", t.ID), zap.Error(err)) } return nil } // ResendVerification re-fires the verification email for the currently-logged-in // user. No-ops with no error if the user is already verified. func (s *AuthService) ResendVerification(ctx context.Context, userID string) error { u, err := s.users.FindByID(ctx, userID) if err != nil { return ErrUserNotFound } if u.EmailVerifiedAt != nil { return ErrEmailAlreadyVerified } return s.sendVerificationEmail(ctx, u) } // --- internal helpers --- func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) { tok, exp, err := s.jwt.Issue(u.ID, u.TokenVersion) if err != nil { return nil, err } return &AuthResult{User: u, Token: tok, Expires: exp}, nil } func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User) error { _ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeEmailVerification) raw, hash, err := newToken() if err != nil { return err } if _, err := s.tokens.Create(ctx, u.ID, model.PurposeEmailVerification, hash, s.EmailVerifyTTL); err != nil { return err } link := fmt.Sprintf("%s/verify-email?token=%s", s.frontend, raw) body := verifyEmailMessage(u.Name, link) return s.mail.Send(u.Email, "Verify your email — Prabhupāda Says", body) } // newToken returns (rawHex, sha256HexHash). The raw value goes in the // emailed link; only the hash is stored in the DB. func newToken() (raw, hash string, err error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", "", err } raw = hex.EncodeToString(b) hash = hashToken(raw) return raw, hash, nil } // slugForHandle turns the local-part of an email into a kebab-case handle. // Falls back to "user" for fully-stripped inputs so we always have a seed // the retry loop can append a suffix to. func slugForHandle(email string) string { at := strings.IndexByte(email, '@') local := email if at >= 0 { local = email[:at] } var b strings.Builder b.Grow(len(local)) for _, r := range strings.ToLower(local) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) case r == '.', r == '_', r == '-': b.WriteByte('-') } } out := strings.Trim(b.String(), "-") if out == "" { out = "user" } if len(out) > 32 { out = out[:32] } return out } // randomHandleSuffix returns 5 hex characters (~20 bits of entropy) — plenty // to disambiguate names that already exist in the user base. func randomHandleSuffix() string { b := make([]byte, 3) _, _ = rand.Read(b) return hex.EncodeToString(b)[:5] } func hashToken(raw string) string { sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } // --- email bodies (intentionally minimal HTML; auth mails should be plain) --- func passwordResetEmail(name, link string) string { return fmt.Sprintf(`

Hari Bol %s,

You (or someone using your email) asked to reset your password on Prabhupāda Says. Click the link below to set a new one:

%s

This link expires in 1 hour. If you didn't request a reset, just ignore this email — your password stays the same.

`, htmlEscape(name), link, link) } func verifyEmailMessage(name, link string) string { return fmt.Sprintf(`

Hari Bol %s,

Welcome to Prabhupāda Says. Please confirm your email by clicking the link below:

%s

This link expires in 24 hours. You can request a new one from the banner shown at the top of the site after you log in.

`, htmlEscape(name), link, link) } func htmlEscape(s string) string { r := strings.NewReplacer( "&", "&", "<", "<", ">", ">", "\"", """, ) return r.Replace(s) }