509 lines
17 KiB
Go
509 lines
17 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
|
|
}
|
|
|
|
// 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(`<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)
|
|
}
|