628 lines
21 KiB
Go
628 lines
21 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/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
|
|
}
|
|
|
|
// 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. 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
|
|
}
|
|
|
|
// 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 htmlEscape(s string) string {
|
|
r := strings.NewReplacer(
|
|
"&", "&",
|
|
"<", "<",
|
|
">", ">",
|
|
"\"", """,
|
|
)
|
|
return r.Replace(s)
|
|
}
|