// 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/binary" "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" "github.com/prabhupadasays/spsays-bff/pkg/revalidate" "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") ErrAccountSuspended = errors.New("this account has been suspended") ) // 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 reval *revalidate.Client // optional — pings sitemap revalidation on new signups // TTLs — surfaced as fields so tests can shrink them. PasswordResetTTL time.Duration EmailVerifyTTL time.Duration } // SetRevalidator wires the frontend cache-revalidation client. Optional — // when unset, new-signup sitemap pings are simply skipped. func (s *AuthService) SetRevalidator(r *revalidate.Client) { s.reval = r } // 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, } } // CaptureCountry records the account's country only if it isn't already // set. Best-effort: a blank code is a no-op, and DB errors are logged but // swallowed so the caller (a post-signup goroutine) never fails on it. func (s *AuthService) CaptureCountry(ctx context.Context, userID, country string) { if strings.TrimSpace(country) == "" { return } if err := s.users.SetCountryIfEmpty(ctx, userID, country); err != nil { s.logger.Warn("capture signup country failed", zap.String("user_id", userID), zap.Error(err)) } } // 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)) } // New public profile → refresh the sitemap so it's crawlable promptly. s.reval.Trigger("sitemap") return s.issueSession(u) } // LoginResult is the two-state outcome of LogIn: // // - `Auth != nil` — credentials verified AND email is fine. Caller // sets the session cookie and returns the user. // - `OTPRequired == true` — credentials verified but the account has been // unverified for more than 30 days. An OTP has been emailed; the caller // must collect it from the user and call VerifyLoginOTP to complete the // sign-in. No session cookie is set yet. // // One of the two is always set on a non-error return. type LoginResult struct { Auth *AuthResult OTPRequired bool } // staleUnverifiedAge is how long an account can stay unverified before // every subsequent email/password login is gated on an OTP. Anything // shorter and a normal user who simply hasn't checked their inbox yet // would hit the gate; longer and abandoned-but-not-verified accounts // remain a forever-credential. 30 days is the documented requirement. const staleUnverifiedAge = 30 * 24 * time.Hour // 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. May return early with OTPRequired=true; see LoginResult. func (s *AuthService) LogIn(ctx context.Context, email, password string) (*LoginResult, 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 } // Suspended accounts can't sign in (checked after the password so we don't // reveal ban status to someone who doesn't know the password). if u.BannedAt != nil { return nil, ErrAccountSuspended } // OTP gate — unverified email AND account is older than the grace // window. Mint an OTP and signal the caller to collect it. We do NOT // issue a session here. if u.EmailVerifiedAt == nil && time.Since(u.CreatedAt) > staleUnverifiedAge { if u.Email == nil || *u.Email == "" { // No email to send to → fall through to "invalid credentials" // rather than expose this corner case. return nil, ErrInvalidCredentials } if err := s.sendLoginOTP(ctx, u); err != nil { s.logger.Warn("login otp send failed", zap.String("user", u.ID), zap.Error(err)) return nil, fmt.Errorf("send login otp: %w", err) } return &LoginResult{OTPRequired: true}, nil } res, err := s.issueSession(u) if err != nil { return nil, err } return &LoginResult{Auth: res}, nil } // VerifyLoginOTP completes a login that was paused by the stale-unverified // OTP gate. Re-checks the password (so an attacker who steals an OTP email // still can't sign in without the password), validates the OTP, flips // email_verified_at to now(), and issues a fresh session. func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp string) (*AuthResult, error) { u, err := s.users.FindByEmail(ctx, email) if err != nil { return nil, ErrInvalidCredentials } if u.PasswordHash == nil { return nil, ErrInvalidCredentials } if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil { return nil, ErrInvalidCredentials } if u.BannedAt != nil { return nil, ErrAccountSuspended } otpHash := hashToken(otp) tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP) if err != nil { return nil, ErrInvalidOrExpiredLink } if err := s.tokens.Consume(ctx, tok.ID); err != nil { return nil, err } if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil { return nil, err } u2, err := s.users.FindByID(ctx, u.ID) if err != nil { return nil, err } return s.issueSession(u2) } // sendLoginOTP mints a fresh 6-digit code, invalidates any older pending // codes, stores the SHA-256 hash, and emails the raw code to the user. func (s *AuthService) sendLoginOTP(ctx context.Context, u *model.User) error { if u.Email == nil || *u.Email == "" { return errors.New("no email on file") } _ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeLoginOTP) otp, hash, err := newOTP() if err != nil { return err } if _, err := s.tokens.Create(ctx, u.ID, model.PurposeLoginOTP, hash, 10*time.Minute); err != nil { return err } body := loginOTPEmail(u.Name, otp) return s.mail.Send(*u.Email, "Your login code — Prabhupāda Says", body) } // newOTP returns a freshly-generated 6-digit code + its SHA-256 hex hash. // Modulo bias on a uniform 32-bit source is negligible for the 1M keyspace // (10⁶ / 2³² ≈ 4 parts in 10⁴) and irrelevant against rate-limited online // guessing, which is the only attack model we care about for this size. func newOTP() (raw, hash string, err error) { var b [4]byte if _, err := rand.Read(b[:]); err != nil { return "", "", err } n := binary.BigEndian.Uint32(b[:]) % 1_000_000 raw = fmt.Sprintf("%06d", n) hash = hashToken(raw) return raw, hash, nil } // 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 } // ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at // startup). Handler maps to 503. var ErrOAuthUnavailable = errors.New("oauth service unavailable") // ErrOAuthAccountClaimed is returned by LinkOAuthToCurrentUser when the // provider identity is already linked to a different user. We refuse to // move the linkage — the rightful owner of the OAuth account at the // provider can sign in via the normal flow. var ErrOAuthAccountClaimed = errors.New("oauth account already linked to another user") // LinkOAuthToCurrentUser ties an OAuth provider identity to a specific // already-authenticated user (settings → Connect flow). Refuses to move // the linkage if some other user already claims it. // // Email policy: the user's primary email is NEVER touched by this call. // The provider's email is stored on the oauth_accounts row only for // reference (display in the future, audit). If the user wants to change // their primary email they have to do it explicitly via SetEmail. func (s *AuthService) LinkOAuthToCurrentUser(ctx context.Context, userID string, prof OAuthProfile) error { if s.oauth == nil { return ErrOAuthUnavailable } // If this (provider, sub) is already linked, the only valid case is // that it's linked to the SAME user we're trying to link to. Any other // user owning this linkage means the OAuth account was already claimed // (typically the user mis-clicked or it's a different person on a // shared device) — refuse rather than silently steal it. if existing, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil { if existing.UserID != userID { return ErrOAuthAccountClaimed } // Same user — idempotent: refresh the denormalised profile fields // and return success. _, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID, optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)) return err } else if !errors.Is(err, repository.ErrOAuthAccountNotFound) { return err } // Fresh link. _, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID, optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)) return err } // SignInWithOAuth turns a provider profile into a session. Three-step lookup: // // 1. Existing linkage on (provider, provider_user_id) → log that user in. // 2. Provider gave us an email AND an existing user has the same email → // link this provider to them, log in. // 3. Otherwise create a new user (no password, email_verified iff we have // an email) and link. // // When the provider returns no email (Facebook accounts with no verified // email on file, accounts that declined the email scope, etc.) we skip // the merge step and create the user with email=NULL. They can add an // email later via PATCH /users/me — the frontend prompts them. // // When we DO have an email, every successful OAuth round-trip is treated // as proof the user controls it — Google + Facebook both verify email // ownership before returning it, so we mark the email as verified. // // 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)) if prof.Email != "" { s.ensureEmailVerified(ctx, u) } return s.issueSession(u) } else if !errors.Is(err, repository.ErrOAuthAccountNotFound) { return nil, err } // 2. Existing user with the same email → link. // Only attempt this when the provider actually gave us an email. With // no email there's no merge key — we just create a fresh user below. if prof.Email != "" { 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 == "" && prof.Email != "" { displayName = strings.SplitN(prof.Email, "@", 2)[0] } if displayName == "" { // No name AND no email — fall back to the provider name so the user // at least has a non-empty display name they can rename later. displayName = strings.Title(prof.Provider) + " user" //nolint:staticcheck // legacy Title is fine for ASCII labels } base := slugForHandle(prof.Email) if base == "user" && prof.ProviderUserID != "" { // slugForHandle returns "user" for empty input — prefer something // less collision-prone for OAuth-no-email signups. base = "user-" + safeSlugSuffix(prof.ProviderUserID) } emailVerified := prof.Email != "" // can only be verified if we have an 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()) } u, err = s.users.CreateOAuth(ctx, optStr(prof.Email), displayName, handle, optStr(prof.AvatarURL), emailVerified) if err == nil { break } if errors.Is(err, repository.ErrEmailTaken) { // Race: someone else just claimed this email. Fall back to step 2. // Only reachable when prof.Email != "" (NULL emails never collide). 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 } // New public profile → refresh the sitemap so it's crawlable promptly. s.reval.Trigger("sitemap") return s.issueSession(u) } // safeSlugSuffix reduces an arbitrary provider id to a short ASCII tail // suitable as part of a handle. Provider ids are typically all-digit // (Facebook) or short ASCII (Google sub), so a trailing 8-char window is // plenty and avoids handles getting silly-long. func safeSlugSuffix(s string) string { out := strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': return r case r >= 'A' && r <= 'Z': return r + ('a' - 'A') default: return -1 } }, s) if len(out) > 8 { out = out[len(out)-8:] } if out == "" { return randomHandleSuffix() } return out } // 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 } // LinkedProviders returns the OAuth provider names already linked to the // caller's account ("google", "facebook"). Used by the settings UI to flip // the per-provider rows between "Connect" and "Connected". func (s *AuthService) LinkedProviders(ctx context.Context, userID string) ([]string, error) { if s.oauth == nil { // OAuth repo not wired (no Postgres). No-op rather than erroring — // settings just shows everything as un-connected. return []string{}, nil } return s.oauth.ListProvidersForUser(ctx, userID) } // ChangePassword sets the caller's password, in two modes: // // 1. Account HAS a password — caller must supply the current one. We // verify with bcrypt before replacing, defending against an attacker // who got hold of the session cookie but doesn't know the password. // 2. Account has NO password yet (OAuth-only signup) — caller can set // one without proving anything beyond their existing session. There's // no current password to verify against, and the session already // grants them everything the new password would, so requiring an // email round-trip here would only be friction. // // In both cases token_version is bumped (invalidating every JWT ever // issued to this user) and a fresh session is re-issued so the calling // device stays logged in while every other device is signed out. func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) (*AuthResult, error) { if len(newPassword) < 8 { return nil, ErrWeakPassword } u, err := s.users.FindByID(ctx, userID) if err != nil { return nil, ErrUserNotFound } if u.PasswordHash != nil { // Account has a password — current must match before we'll replace it. if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(currentPassword)); err != nil { return nil, ErrInvalidCredentials } } // If u.PasswordHash == nil we skip the verification: setting an initial // password for an OAuth-only account doesn't require proving anything // beyond the active session. hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("hash password: %w", err) } if err := s.users.UpdatePassword(ctx, userID, string(hash)); err != nil { return nil, err } // Re-fetch to pick up the new token_version so the freshly-issued JWT // has the right "tv" claim and the cookie set after this call keeps // the user logged in on this device. u2, err := s.users.FindByID(ctx, userID) if err != nil { return nil, err } return s.issueSession(u2) } // SetEmail fills in (or replaces) the caller's email address. Used by the // "add your email" flow for OAuth users who signed up without one, and by // any user who later wants to change their address. // // The address is stored as unverified — a verification link is sent to it // immediately. Returns ErrEmailAlreadyTaken if another row already owns it. func (s *AuthService) SetEmail(ctx context.Context, userID, email string) (*model.User, error) { u, err := s.users.SetEmail(ctx, userID, email) if err != nil { if errors.Is(err, repository.ErrEmailTaken) { return nil, ErrEmailAlreadyTaken } return nil, err } if err := s.sendVerificationEmail(ctx, u); err != nil { // Verification email failure shouldn't block the set — the user can // always trigger a resend. s.logger.Warn("verification email failed after SetEmail", zap.String("user", u.ID), zap.Error(err)) } return u, nil } // 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 } // OAuth-only users may have no email on file — skip the send silently // (the caller already cannot tell whether the email was registered, so // "no email to send to" surfaces the same way). if u.Email == nil || *u.Email == "" { return nil } 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 { // Users with no email on file (OAuth signups where the provider didn't // return one) have nothing to send to. The caller is expected to gate // on this, but defensively no-op here too. if u.Email == nil || *u.Email == "" { return nil } _ = 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 loginOTPEmail(name, otp string) string { return fmt.Sprintf(`

Hari Bol %s,

Your account on Prabhupāda Says has been unverified for a while, so we need a quick one-time code before signing you in:

%s

This code expires in 10 minutes. If you didn't try to sign in, you can safely ignore this email.

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