golfgame/docs/v2/V2_03_USER_ACCOUNTS.md
Aaron D. Lee bea85e6b28 Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:32:15 -05:00

40 KiB

V2-03: User Accounts & Authentication

Overview

This document covers the complete user account lifecycle: registration, email verification, login, password reset, session management, and account settings.

Dependencies: V2-02 (Persistence - PostgreSQL setup) Dependents: V2-04 (Admin Tools), V2-05 (Stats/Leaderboards)


Goals

  1. Email service integration (Resend)
  2. User registration with email verification
  3. Password reset via email
  4. Session management (view/revoke)
  5. Account settings and preferences
  6. Guest-to-user conversion flow
  7. Account deletion (GDPR-friendly)

Current State

Basic auth exists in auth.py:

  • Username/password authentication
  • Session tokens stored in SQLite
  • Admin role support
  • Invite codes for registration

Missing:

  • Email integration
  • Email verification
  • Password reset flow
  • Session management UI
  • Account deletion
  • Guest accounts

User Flow Diagrams

Registration Flow

┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
│  Enter   │     │  Create  │     │  Send    │     │  Click   │
│  Email + │────►│  Pending │────►│  Verify  │────►│  Verify  │
│  Password│     │  Account │     │  Email   │     │  Link    │
└──────────┘     └──────────┘     └──────────┘     └──────────┘
                                                         │
                                                         ▼
                                                   ┌──────────┐
                                                   │  Account │
                                                   │  Active  │
                                                   └──────────┘

Password Reset Flow

┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
│  Enter   │     │  Generate│     │  Send    │     │  Click   │
│  Email   │────►│  Reset   │────►│  Reset   │────►│  Reset   │
│          │     │  Token   │     │  Email   │     │  Link    │
└──────────┘     └──────────┘     └──────────┘     └──────────┘
                                                         │
                                                         ▼
                                                   ┌──────────┐
                                                   │  Enter   │
                                                   │  New     │
                                                   │  Password│
                                                   └──────────┘

Guest Conversion Flow

┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
│  Play as │     │  Prompt  │     │  Enter   │     │  Link    │
│  Guest   │────►│  "Save   │────►│  Email + │────►│  Guest   │
│          │     │  Stats?" │     │  Password│     │  to User │
└──────────┘     └──────────┘     └──────────┘     └──────────┘

Database Schema

-- migrations/versions/002_user_accounts.sql

-- Extend existing users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false;
ALTER TABLE users ADD COLUMN IF NOT EXISTS verification_token VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS verification_expires TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS reset_token VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS reset_expires TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS guest_id VARCHAR(50);  -- Links to guest session
ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;  -- Soft delete
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences JSONB DEFAULT '{}';

-- Sessions table (replace or extend existing)
CREATE TABLE IF NOT EXISTS user_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash VARCHAR(255) NOT NULL UNIQUE,
    device_info JSONB DEFAULT '{}',
    ip_address INET,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL,
    last_used_at TIMESTAMPTZ DEFAULT NOW(),
    revoked_at TIMESTAMPTZ,

    -- Index for token lookups
    CONSTRAINT idx_sessions_token UNIQUE (token_hash)
);

-- Guest sessions (for guest-to-user conversion)
CREATE TABLE IF NOT EXISTS guest_sessions (
    id VARCHAR(50) PRIMARY KEY,  -- UUID stored as string
    display_name VARCHAR(50) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    last_seen_at TIMESTAMPTZ DEFAULT NOW(),
    games_played INT DEFAULT 0,
    converted_to_user_id UUID REFERENCES users(id),

    -- Expire after 30 days of inactivity
    expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '30 days'
);

-- Email log (for debugging/audit)
CREATE TABLE IF NOT EXISTS email_log (
    id BIGSERIAL PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    email_type VARCHAR(50) NOT NULL,  -- verification, password_reset, etc.
    recipient VARCHAR(255) NOT NULL,
    sent_at TIMESTAMPTZ DEFAULT NOW(),
    resend_id VARCHAR(100),  -- ID from email provider
    status VARCHAR(20) DEFAULT 'sent'
);

-- Indexes
CREATE INDEX idx_users_email ON users(email) WHERE email IS NOT NULL;
CREATE INDEX idx_users_guest ON users(guest_id) WHERE guest_id IS NOT NULL;
CREATE INDEX idx_sessions_user ON user_sessions(user_id);
CREATE INDEX idx_sessions_expires ON user_sessions(expires_at) WHERE revoked_at IS NULL;
CREATE INDEX idx_guests_expires ON guest_sessions(expires_at);

Email Service

# server/services/email_service.py
import resend
from typing import Optional
from datetime import datetime
import os

from config import config


class EmailService:
    """Email sending via Resend."""

    def __init__(self):
        resend.api_key = config.RESEND_API_KEY
        self.from_email = config.EMAIL_FROM  # e.g., "Golf Game <noreply@yourdomain.com>"
        self.base_url = config.BASE_URL  # e.g., "https://golf.yourdomain.com"

    async def send_verification_email(
        self,
        to_email: str,
        username: str,
        verification_token: str,
    ) -> Optional[str]:
        """Send email verification link."""
        verify_url = f"{self.base_url}/verify?token={verification_token}"

        try:
            response = resend.Emails.send({
                "from": self.from_email,
                "to": to_email,
                "subject": "Verify your Golf Game account",
                "html": f"""
                <h2>Welcome to Golf Game, {username}!</h2>
                <p>Please verify your email address by clicking the link below:</p>
                <p><a href="{verify_url}" style="
                    background-color: #4CAF50;
                    color: white;
                    padding: 14px 20px;
                    text-decoration: none;
                    display: inline-block;
                    border-radius: 4px;
                ">Verify Email</a></p>
                <p>Or copy this link: {verify_url}</p>
                <p>This link expires in 24 hours.</p>
                <p>If you didn't create this account, you can ignore this email.</p>
                """,
                "text": f"""
                Welcome to Golf Game, {username}!

                Please verify your email address by visiting:
                {verify_url}

                This link expires in 24 hours.

                If you didn't create this account, you can ignore this email.
                """,
            })
            return response.get("id")
        except Exception as e:
            print(f"Failed to send verification email: {e}")
            return None

    async def send_password_reset_email(
        self,
        to_email: str,
        username: str,
        reset_token: str,
    ) -> Optional[str]:
        """Send password reset link."""
        reset_url = f"{self.base_url}/reset-password?token={reset_token}"

        try:
            response = resend.Emails.send({
                "from": self.from_email,
                "to": to_email,
                "subject": "Reset your Golf Game password",
                "html": f"""
                <h2>Password Reset Request</h2>
                <p>Hi {username},</p>
                <p>We received a request to reset your password. Click the link below to choose a new password:</p>
                <p><a href="{reset_url}" style="
                    background-color: #2196F3;
                    color: white;
                    padding: 14px 20px;
                    text-decoration: none;
                    display: inline-block;
                    border-radius: 4px;
                ">Reset Password</a></p>
                <p>Or copy this link: {reset_url}</p>
                <p>This link expires in 1 hour.</p>
                <p>If you didn't request this, you can ignore this email. Your password won't be changed.</p>
                """,
                "text": f"""
                Password Reset Request

                Hi {username},

                We received a request to reset your password. Visit this link to choose a new password:
                {reset_url}

                This link expires in 1 hour.

                If you didn't request this, you can ignore this email. Your password won't be changed.
                """,
            })
            return response.get("id")
        except Exception as e:
            print(f"Failed to send password reset email: {e}")
            return None

    async def send_password_changed_notification(
        self,
        to_email: str,
        username: str,
    ) -> Optional[str]:
        """Notify user their password was changed."""
        try:
            response = resend.Emails.send({
                "from": self.from_email,
                "to": to_email,
                "subject": "Your Golf Game password was changed",
                "html": f"""
                <h2>Password Changed</h2>
                <p>Hi {username},</p>
                <p>Your Golf Game password was recently changed.</p>
                <p>If you made this change, you can ignore this email.</p>
                <p>If you didn't change your password, please <a href="{self.base_url}/reset-password">reset it immediately</a> and contact support.</p>
                """,
                "text": f"""
                Password Changed

                Hi {username},

                Your Golf Game password was recently changed.

                If you made this change, you can ignore this email.

                If you didn't change your password, please reset it immediately at:
                {self.base_url}/reset-password
                """,
            })
            return response.get("id")
        except Exception as e:
            print(f"Failed to send password changed notification: {e}")
            return None

Auth Service

# server/services/auth_service.py
import secrets
import hashlib
from datetime import datetime, timedelta
from typing import Optional, Tuple
from dataclasses import dataclass
import asyncpg
from passlib.hash import bcrypt

from services.email_service import EmailService


@dataclass
class User:
    id: str
    username: str
    email: Optional[str]
    role: str
    email_verified: bool
    created_at: datetime
    preferences: dict


@dataclass
class Session:
    id: str
    user_id: str
    device_info: dict
    ip_address: str
    created_at: datetime
    expires_at: datetime
    last_used_at: datetime


class AuthService:
    """User authentication and account management."""

    TOKEN_EXPIRY_HOURS = 24 * 7  # 1 week
    VERIFICATION_EXPIRY_HOURS = 24
    RESET_EXPIRY_HOURS = 1

    def __init__(self, db_pool: asyncpg.Pool, email_service: EmailService):
        self.db = db_pool
        self.email = email_service

    # --- Registration ---

    async def register(
        self,
        username: str,
        email: str,
        password: str,
        guest_id: Optional[str] = None,
    ) -> Tuple[Optional[User], Optional[str]]:
        """
        Register a new user.
        Returns (user, error_message).
        """
        # Validate input
        if len(username) < 3 or len(username) > 30:
            return None, "Username must be 3-30 characters"

        if not self._is_valid_email(email):
            return None, "Invalid email address"

        if len(password) < 8:
            return None, "Password must be at least 8 characters"

        async with self.db.acquire() as conn:
            # Check if username or email exists
            existing = await conn.fetchrow("""
                SELECT id FROM users
                WHERE username = $1 OR email = $2
            """, username, email)

            if existing:
                return None, "Username or email already registered"

            # Generate verification token
            verification_token = secrets.token_urlsafe(32)
            verification_expires = datetime.utcnow() + timedelta(
                hours=self.VERIFICATION_EXPIRY_HOURS
            )

            # Hash password
            password_hash = bcrypt.hash(password)

            # Create user
            user_id = secrets.token_urlsafe(16)

            await conn.execute("""
                INSERT INTO users (
                    id, username, email, password_hash, role,
                    email_verified, verification_token, verification_expires,
                    guest_id, created_at
                ) VALUES ($1, $2, $3, $4, 'user', false, $5, $6, $7, NOW())
            """, user_id, username, email, password_hash,
                verification_token, verification_expires, guest_id)

            # If converting from guest, link stats
            if guest_id:
                await self._convert_guest_stats(conn, guest_id, user_id)

        # Send verification email
        await self.email.send_verification_email(
            email, username, verification_token
        )

        return User(
            id=user_id,
            username=username,
            email=email,
            role="user",
            email_verified=False,
            created_at=datetime.utcnow(),
            preferences={},
        ), None

    async def verify_email(self, token: str) -> Tuple[bool, str]:
        """
        Verify email with token.
        Returns (success, message).
        """
        async with self.db.acquire() as conn:
            user = await conn.fetchrow("""
                SELECT id, username, verification_expires
                FROM users
                WHERE verification_token = $1
                AND deleted_at IS NULL
            """, token)

            if not user:
                return False, "Invalid verification link"

            if user["verification_expires"] < datetime.utcnow():
                return False, "Verification link has expired"

            await conn.execute("""
                UPDATE users
                SET email_verified = true,
                    verification_token = NULL,
                    verification_expires = NULL
                WHERE id = $1
            """, user["id"])

            return True, "Email verified successfully"

    async def resend_verification(self, email: str) -> Tuple[bool, str]:
        """Resend verification email."""
        async with self.db.acquire() as conn:
            user = await conn.fetchrow("""
                SELECT id, username, email_verified
                FROM users
                WHERE email = $1
                AND deleted_at IS NULL
            """, email)

            if not user:
                # Don't reveal if email exists
                return True, "If that email is registered, a verification link has been sent"

            if user["email_verified"]:
                return False, "Email is already verified"

            # Generate new token
            verification_token = secrets.token_urlsafe(32)
            verification_expires = datetime.utcnow() + timedelta(
                hours=self.VERIFICATION_EXPIRY_HOURS
            )

            await conn.execute("""
                UPDATE users
                SET verification_token = $1, verification_expires = $2
                WHERE id = $3
            """, verification_token, verification_expires, user["id"])

        await self.email.send_verification_email(
            email, user["username"], verification_token
        )

        return True, "Verification email sent"

    # --- Login ---

    async def login(
        self,
        username_or_email: str,
        password: str,
        device_info: dict,
        ip_address: str,
    ) -> Tuple[Optional[str], Optional[User], Optional[str]]:
        """
        Login user.
        Returns (session_token, user, error_message).
        """
        async with self.db.acquire() as conn:
            user = await conn.fetchrow("""
                SELECT id, username, email, password_hash, role,
                       email_verified, preferences, created_at
                FROM users
                WHERE (username = $1 OR email = $1)
                AND deleted_at IS NULL
            """, username_or_email)

            if not user:
                return None, None, "Invalid username or password"

            if not bcrypt.verify(password, user["password_hash"]):
                return None, None, "Invalid username or password"

            # Check email verification (optional - can allow login without)
            # if not user["email_verified"]:
            #     return None, None, "Please verify your email first"

            # Create session
            session_token = secrets.token_urlsafe(32)
            token_hash = hashlib.sha256(session_token.encode()).hexdigest()
            expires_at = datetime.utcnow() + timedelta(hours=self.TOKEN_EXPIRY_HOURS)

            await conn.execute("""
                INSERT INTO user_sessions (
                    user_id, token_hash, device_info, ip_address, expires_at
                ) VALUES ($1, $2, $3, $4, $5)
            """, user["id"], token_hash, device_info, ip_address, expires_at)

            # Update last login
            await conn.execute("""
                UPDATE users SET last_seen_at = NOW() WHERE id = $1
            """, user["id"])

            return session_token, User(
                id=user["id"],
                username=user["username"],
                email=user["email"],
                role=user["role"],
                email_verified=user["email_verified"],
                created_at=user["created_at"],
                preferences=user["preferences"] or {},
            ), None

    async def logout(self, session_token: str) -> bool:
        """Logout (revoke session)."""
        token_hash = hashlib.sha256(session_token.encode()).hexdigest()

        async with self.db.acquire() as conn:
            result = await conn.execute("""
                UPDATE user_sessions
                SET revoked_at = NOW()
                WHERE token_hash = $1
                AND revoked_at IS NULL
            """, token_hash)

            return result != "UPDATE 0"

    async def logout_all(self, user_id: str, except_token: Optional[str] = None) -> int:
        """Logout all sessions for a user."""
        async with self.db.acquire() as conn:
            if except_token:
                except_hash = hashlib.sha256(except_token.encode()).hexdigest()
                result = await conn.execute("""
                    UPDATE user_sessions
                    SET revoked_at = NOW()
                    WHERE user_id = $1
                    AND revoked_at IS NULL
                    AND token_hash != $2
                """, user_id, except_hash)
            else:
                result = await conn.execute("""
                    UPDATE user_sessions
                    SET revoked_at = NOW()
                    WHERE user_id = $1
                    AND revoked_at IS NULL
                """, user_id)

            # Parse "UPDATE N" to get count
            return int(result.split()[1])

    async def validate_session(self, session_token: str) -> Optional[User]:
        """Validate session token, return user if valid."""
        token_hash = hashlib.sha256(session_token.encode()).hexdigest()

        async with self.db.acquire() as conn:
            row = await conn.fetchrow("""
                SELECT u.id, u.username, u.email, u.role,
                       u.email_verified, u.preferences, u.created_at,
                       s.id as session_id
                FROM user_sessions s
                JOIN users u ON s.user_id = u.id
                WHERE s.token_hash = $1
                AND s.revoked_at IS NULL
                AND s.expires_at > NOW()
                AND u.deleted_at IS NULL
            """, token_hash)

            if not row:
                return None

            # Update last used
            await conn.execute("""
                UPDATE user_sessions SET last_used_at = NOW() WHERE id = $1
            """, row["session_id"])

            return User(
                id=row["id"],
                username=row["username"],
                email=row["email"],
                role=row["role"],
                email_verified=row["email_verified"],
                created_at=row["created_at"],
                preferences=row["preferences"] or {},
            )

    # --- Password Reset ---

    async def request_password_reset(self, email: str) -> Tuple[bool, str]:
        """Request password reset email."""
        async with self.db.acquire() as conn:
            user = await conn.fetchrow("""
                SELECT id, username, email
                FROM users
                WHERE email = $1
                AND deleted_at IS NULL
            """, email)

            if not user:
                # Don't reveal if email exists
                return True, "If that email is registered, a reset link has been sent"

            # Generate reset token
            reset_token = secrets.token_urlsafe(32)
            reset_expires = datetime.utcnow() + timedelta(
                hours=self.RESET_EXPIRY_HOURS
            )

            await conn.execute("""
                UPDATE users
                SET reset_token = $1, reset_expires = $2
                WHERE id = $3
            """, reset_token, reset_expires, user["id"])

        await self.email.send_password_reset_email(
            email, user["username"], reset_token
        )

        return True, "Reset link sent"

    async def reset_password(
        self,
        token: str,
        new_password: str,
    ) -> Tuple[bool, str]:
        """Reset password with token."""
        if len(new_password) < 8:
            return False, "Password must be at least 8 characters"

        async with self.db.acquire() as conn:
            user = await conn.fetchrow("""
                SELECT id, username, email, reset_expires
                FROM users
                WHERE reset_token = $1
                AND deleted_at IS NULL
            """, token)

            if not user:
                return False, "Invalid reset link"

            if user["reset_expires"] < datetime.utcnow():
                return False, "Reset link has expired"

            # Update password
            password_hash = bcrypt.hash(new_password)

            await conn.execute("""
                UPDATE users
                SET password_hash = $1,
                    reset_token = NULL,
                    reset_expires = NULL
                WHERE id = $2
            """, password_hash, user["id"])

            # Revoke all sessions (security)
            await conn.execute("""
                UPDATE user_sessions
                SET revoked_at = NOW()
                WHERE user_id = $1
            """, user["id"])

        # Notify user
        await self.email.send_password_changed_notification(
            user["email"], user["username"]
        )

        return True, "Password updated successfully"

    async def change_password(
        self,
        user_id: str,
        current_password: str,
        new_password: str,
    ) -> Tuple[bool, str]:
        """Change password (when logged in)."""
        if len(new_password) < 8:
            return False, "Password must be at least 8 characters"

        async with self.db.acquire() as conn:
            user = await conn.fetchrow("""
                SELECT id, username, email, password_hash
                FROM users
                WHERE id = $1
                AND deleted_at IS NULL
            """, user_id)

            if not user:
                return False, "User not found"

            if not bcrypt.verify(current_password, user["password_hash"]):
                return False, "Current password is incorrect"

            # Update password
            password_hash = bcrypt.hash(new_password)

            await conn.execute("""
                UPDATE users SET password_hash = $1 WHERE id = $2
            """, password_hash, user["id"])

        # Notify user
        if user["email"]:
            await self.email.send_password_changed_notification(
                user["email"], user["username"]
            )

        return True, "Password updated successfully"

    # --- Session Management ---

    async def get_sessions(self, user_id: str) -> list[Session]:
        """Get all active sessions for a user."""
        async with self.db.acquire() as conn:
            rows = await conn.fetch("""
                SELECT id, user_id, device_info, ip_address,
                       created_at, expires_at, last_used_at
                FROM user_sessions
                WHERE user_id = $1
                AND revoked_at IS NULL
                AND expires_at > NOW()
                ORDER BY last_used_at DESC
            """, user_id)

            return [
                Session(
                    id=row["id"],
                    user_id=row["user_id"],
                    device_info=row["device_info"] or {},
                    ip_address=str(row["ip_address"]) if row["ip_address"] else "",
                    created_at=row["created_at"],
                    expires_at=row["expires_at"],
                    last_used_at=row["last_used_at"],
                )
                for row in rows
            ]

    async def revoke_session(self, user_id: str, session_id: str) -> bool:
        """Revoke a specific session."""
        async with self.db.acquire() as conn:
            result = await conn.execute("""
                UPDATE user_sessions
                SET revoked_at = NOW()
                WHERE id = $1
                AND user_id = $2
                AND revoked_at IS NULL
            """, session_id, user_id)

            return result != "UPDATE 0"

    # --- Account Management ---

    async def update_preferences(
        self,
        user_id: str,
        preferences: dict,
    ) -> bool:
        """Update user preferences."""
        async with self.db.acquire() as conn:
            await conn.execute("""
                UPDATE users
                SET preferences = preferences || $1
                WHERE id = $2
            """, preferences, user_id)

            return True

    async def delete_account(
        self,
        user_id: str,
        password: str,
    ) -> Tuple[bool, str]:
        """
        Soft-delete account.
        Anonymizes data but preserves game history.
        """
        async with self.db.acquire() as conn:
            user = await conn.fetchrow("""
                SELECT id, password_hash
                FROM users
                WHERE id = $1
                AND deleted_at IS NULL
            """, user_id)

            if not user:
                return False, "User not found"

            if not bcrypt.verify(password, user["password_hash"]):
                return False, "Incorrect password"

            # Soft delete - anonymize PII but keep ID for game history
            deleted_username = f"deleted_{user_id[:8]}"

            await conn.execute("""
                UPDATE users
                SET username = $1,
                    email = NULL,
                    password_hash = '',
                    deleted_at = NOW(),
                    preferences = '{}'
                WHERE id = $2
            """, deleted_username, user_id)

            # Revoke all sessions
            await conn.execute("""
                UPDATE user_sessions
                SET revoked_at = NOW()
                WHERE user_id = $1
            """, user_id)

            return True, "Account deleted"

    # --- Guest Conversion ---

    async def _convert_guest_stats(
        self,
        conn: asyncpg.Connection,
        guest_id: str,
        user_id: str,
    ) -> None:
        """Transfer guest stats to user account."""
        # Mark guest as converted
        await conn.execute("""
            UPDATE guest_sessions
            SET converted_to_user_id = $1
            WHERE id = $2
        """, user_id, guest_id)

        # Update game records to link to user
        await conn.execute("""
            UPDATE games_v2
            SET player_ids = array_replace(player_ids, $1, $2)
            WHERE $1 = ANY(player_ids)
        """, guest_id, user_id)

    # --- Helpers ---

    def _is_valid_email(self, email: str) -> bool:
        """Basic email validation."""
        import re
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        return bool(re.match(pattern, email))

API Endpoints

# server/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, EmailStr

router = APIRouter(prefix="/api/auth", tags=["auth"])


class RegisterRequest(BaseModel):
    username: str
    email: EmailStr
    password: str
    guest_id: Optional[str] = None


class LoginRequest(BaseModel):
    username_or_email: str
    password: str


class PasswordResetRequest(BaseModel):
    email: EmailStr


class PasswordResetConfirm(BaseModel):
    token: str
    new_password: str


class ChangePasswordRequest(BaseModel):
    current_password: str
    new_password: str


@router.post("/register")
async def register(
    request: RegisterRequest,
    auth: AuthService = Depends(get_auth_service),
):
    user, error = await auth.register(
        request.username,
        request.email,
        request.password,
        request.guest_id,
    )
    if error:
        raise HTTPException(status_code=400, detail=error)

    return {
        "message": "Registration successful. Please check your email to verify your account.",
        "user_id": user.id,
    }


@router.post("/verify-email")
async def verify_email(
    token: str,
    auth: AuthService = Depends(get_auth_service),
):
    success, message = await auth.verify_email(token)
    if not success:
        raise HTTPException(status_code=400, detail=message)
    return {"message": message}


@router.post("/resend-verification")
async def resend_verification(
    email: EmailStr,
    auth: AuthService = Depends(get_auth_service),
):
    success, message = await auth.resend_verification(email)
    return {"message": message}


@router.post("/login")
async def login(
    request: LoginRequest,
    req: Request,
    auth: AuthService = Depends(get_auth_service),
):
    device_info = {
        "user_agent": req.headers.get("user-agent", ""),
    }
    ip_address = req.client.host

    token, user, error = await auth.login(
        request.username_or_email,
        request.password,
        device_info,
        ip_address,
    )

    if error:
        raise HTTPException(status_code=401, detail=error)

    return {
        "token": token,
        "user": {
            "id": user.id,
            "username": user.username,
            "email": user.email,
            "role": user.role,
            "email_verified": user.email_verified,
        },
    }


@router.post("/logout")
async def logout(
    user: User = Depends(get_current_user),
    token: str = Depends(get_token),
    auth: AuthService = Depends(get_auth_service),
):
    await auth.logout(token)
    return {"message": "Logged out"}


@router.post("/logout-all")
async def logout_all(
    user: User = Depends(get_current_user),
    token: str = Depends(get_token),
    auth: AuthService = Depends(get_auth_service),
):
    count = await auth.logout_all(user.id, except_token=token)
    return {"message": f"Logged out {count} other sessions"}


@router.post("/forgot-password")
async def forgot_password(
    request: PasswordResetRequest,
    auth: AuthService = Depends(get_auth_service),
):
    success, message = await auth.request_password_reset(request.email)
    return {"message": message}


@router.post("/reset-password")
async def reset_password(
    request: PasswordResetConfirm,
    auth: AuthService = Depends(get_auth_service),
):
    success, message = await auth.reset_password(request.token, request.new_password)
    if not success:
        raise HTTPException(status_code=400, detail=message)
    return {"message": message}


@router.put("/password")
async def change_password(
    request: ChangePasswordRequest,
    user: User = Depends(get_current_user),
    auth: AuthService = Depends(get_auth_service),
):
    success, message = await auth.change_password(
        user.id,
        request.current_password,
        request.new_password,
    )
    if not success:
        raise HTTPException(status_code=400, detail=message)
    return {"message": message}


@router.get("/sessions")
async def get_sessions(
    user: User = Depends(get_current_user),
    auth: AuthService = Depends(get_auth_service),
):
    sessions = await auth.get_sessions(user.id)
    return {
        "sessions": [
            {
                "id": s.id,
                "device_info": s.device_info,
                "ip_address": s.ip_address,
                "created_at": s.created_at.isoformat(),
                "last_used_at": s.last_used_at.isoformat(),
            }
            for s in sessions
        ]
    }


@router.delete("/sessions/{session_id}")
async def revoke_session(
    session_id: str,
    user: User = Depends(get_current_user),
    auth: AuthService = Depends(get_auth_service),
):
    success = await auth.revoke_session(user.id, session_id)
    if not success:
        raise HTTPException(status_code=404, detail="Session not found")
    return {"message": "Session revoked"}


@router.get("/me")
async def get_me(user: User = Depends(get_current_user)):
    return {
        "id": user.id,
        "username": user.username,
        "email": user.email,
        "role": user.role,
        "email_verified": user.email_verified,
        "preferences": user.preferences,
    }


@router.put("/preferences")
async def update_preferences(
    preferences: dict,
    user: User = Depends(get_current_user),
    auth: AuthService = Depends(get_auth_service),
):
    await auth.update_preferences(user.id, preferences)
    return {"message": "Preferences updated"}


@router.delete("/account")
async def delete_account(
    password: str,
    user: User = Depends(get_current_user),
    auth: AuthService = Depends(get_auth_service),
):
    success, message = await auth.delete_account(user.id, password)
    if not success:
        raise HTTPException(status_code=400, detail=message)
    return {"message": message}

Frontend Integration

Login/Register UI

Add to client/index.html:

<!-- Auth Modal -->
<div id="auth-modal" class="modal hidden">
    <div class="modal-content">
        <div class="auth-tabs">
            <button id="login-tab" class="tab active">Login</button>
            <button id="register-tab" class="tab">Register</button>
        </div>

        <!-- Login Form -->
        <form id="login-form" class="auth-form">
            <input type="text" id="login-username" placeholder="Username or Email" required>
            <input type="password" id="login-password" placeholder="Password" required>
            <a href="#" id="forgot-password-link">Forgot password?</a>
            <button type="submit" class="btn btn-primary">Login</button>
        </form>

        <!-- Register Form -->
        <form id="register-form" class="auth-form hidden">
            <input type="text" id="register-username" placeholder="Username" required>
            <input type="email" id="register-email" placeholder="Email" required>
            <input type="password" id="register-password" placeholder="Password (8+ characters)" required>
            <button type="submit" class="btn btn-primary">Register</button>
        </form>

        <!-- Guest Option -->
        <div class="guest-option">
            <p>or</p>
            <button id="play-as-guest" class="btn btn-secondary">Play as Guest</button>
        </div>

        <p id="auth-error" class="error hidden"></p>
    </div>
</div>

Config Additions

# server/config.py additions

class Config:
    # Email
    RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
    EMAIL_FROM: str = os.getenv("EMAIL_FROM", "Golf Game <noreply@example.com>")
    BASE_URL: str = os.getenv("BASE_URL", "http://localhost:8000")

    # Auth
    SESSION_EXPIRY_HOURS: int = int(os.getenv("SESSION_EXPIRY_HOURS", "168"))  # 1 week
    REQUIRE_EMAIL_VERIFICATION: bool = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true"

Acceptance Criteria

  1. Email Service

    • Resend integration working
    • Verification emails send correctly
    • Password reset emails send correctly
    • Password changed notifications work
    • Email delivery logged
  2. Registration

    • Can register with username/email/password
    • Validation enforced (username length, email format, password strength)
    • Duplicate detection works
    • Verification email sent
    • Guest ID can be linked
  3. Email Verification

    • Verification link works
    • Expired links rejected
    • Can resend verification
    • Verified flag set correctly
  4. Login/Logout

    • Can login with username or email
    • Wrong credentials rejected
    • Session token returned
    • Logout revokes session
    • Logout all works
  5. Password Reset

    • Reset request sends email
    • Reset link works
    • Expired links rejected
    • Password updated correctly
    • All sessions revoked after reset
    • Notification email sent
  6. Session Management

    • Can list active sessions
    • Can revoke individual sessions
    • Session shows device info
    • Expired sessions cleaned up
  7. Account Management

    • Can update preferences
    • Can change password (with current password)
    • Can delete account (with password)
    • Deleted accounts anonymized
    • Game history preserved
  8. Guest Conversion

    • Can play as guest
    • Guest prompted to register
    • Stats transfer on conversion
    • Guest ID linked to user

Implementation Order

  1. Set up Resend account and get API key
  2. Add email service config
  3. Create database migrations
  4. Implement EmailService
  5. Implement AuthService (registration first)
  6. Add API endpoints
  7. Implement login/session management
  8. Implement password reset flow
  9. Add frontend UI
  10. Test full flows

Security Notes

  • Store only token hashes, not tokens
  • Use bcrypt for passwords (work factor 12+)
  • Rate limit auth endpoints (see V2-07)
  • Verification/reset tokens expire
  • Notify on password change
  • Soft-delete preserves audit trail
  • Don't reveal if email exists (timing attacks)