feat: account connect

This commit is contained in:
Krispire Admin
2026-06-02 22:54:41 +05:30
parent cf3a49458a
commit 3f6b7b6636
8 changed files with 429 additions and 22 deletions
+180 -3
View File
@@ -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(
"&", "&amp;",