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

1256 lines
40 KiB
Markdown

# 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
```sql
-- 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
```python
# 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
```python
# 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
```python
# 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`:
```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
```python
# 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)