feat: account connect
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -129,10 +130,32 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
|
||||
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.
|
||||
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) {
|
||||
// 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) {
|
||||
@@ -148,7 +171,97 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return s.issueSession(u)
|
||||
|
||||
// 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
|
||||
@@ -168,6 +281,49 @@ type OAuthProfile struct {
|
||||
// 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.
|
||||
@@ -312,6 +468,18 @@ func optStr(s string) *string {
|
||||
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
|
||||
@@ -616,6 +784,15 @@ clicking the link below:</p>
|
||||
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(
|
||||
"&", "&",
|
||||
|
||||
Reference in New Issue
Block a user