Files
spsays-bff/internal/service/auth_service.go
T

818 lines
29 KiB
Go

// 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"
"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,
}
}
// 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))
}
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
}
// 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
}
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
}
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(`<p>Hari Bol %s,</p>
<p>You (or someone using your email) asked to reset your password on
<strong>Prabhupāda Says</strong>. Click the link below to set a new one:</p>
<p><a href="%s">%s</a></p>
<p>This link expires in 1 hour. If you didn't request a reset, just ignore
this email — your password stays the same.</p>`, htmlEscape(name), link, link)
}
func verifyEmailMessage(name, link string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>Welcome to <strong>Prabhupāda Says</strong>. Please confirm your email by
clicking the link below:</p>
<p><a href="%s">%s</a></p>
<p>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.</p>`, htmlEscape(name), link, link)
}
func loginOTPEmail(name, otp string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>Your account on <strong>Prabhupāda Says</strong> has been unverified for a
while, so we need a quick one-time code before signing you in:</p>
<p style="font-size:1.5rem;font-weight:600;letter-spacing:0.25em;margin:1.25rem 0;">%s</p>
<p>This code expires in 10 minutes. If you didn't try to sign in, you can
safely ignore this email.</p>`, htmlEscape(name), otp)
}
func htmlEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
)
return r.Replace(s)
}