Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
654
server/services/auth_service.py
Normal file
654
server/services/auth_service.py
Normal file
@@ -0,0 +1,654 @@
|
||||
"""
|
||||
Authentication service for Golf game.
|
||||
|
||||
Provides business logic for user registration, login, password management,
|
||||
and session handling.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
|
||||
from config import config
|
||||
from models.user import User, UserRole, UserSession, GuestSession
|
||||
from stores.user_store import UserStore
|
||||
from services.email_service import EmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthResult:
|
||||
"""Result of an authentication operation."""
|
||||
success: bool
|
||||
user: Optional[User] = None
|
||||
token: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistrationResult:
|
||||
"""Result of a registration operation."""
|
||||
success: bool
|
||||
user: Optional[User] = None
|
||||
requires_verification: bool = False
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""
|
||||
Authentication service.
|
||||
|
||||
Handles all authentication business logic:
|
||||
- User registration with optional email verification
|
||||
- Login/logout with session management
|
||||
- Password reset flow
|
||||
- Guest-to-user conversion
|
||||
- Account deletion (soft delete)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_store: UserStore,
|
||||
email_service: EmailService,
|
||||
session_expiry_hours: int = 168,
|
||||
require_email_verification: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize auth service.
|
||||
|
||||
Args:
|
||||
user_store: User persistence store.
|
||||
email_service: Email sending service.
|
||||
session_expiry_hours: Session lifetime in hours.
|
||||
require_email_verification: Whether to require email verification.
|
||||
"""
|
||||
self.user_store = user_store
|
||||
self.email_service = email_service
|
||||
self.session_expiry_hours = session_expiry_hours
|
||||
self.require_email_verification = require_email_verification
|
||||
|
||||
@classmethod
|
||||
async def create(cls, user_store: UserStore) -> "AuthService":
|
||||
"""
|
||||
Create AuthService from config.
|
||||
|
||||
Args:
|
||||
user_store: User persistence store.
|
||||
"""
|
||||
from services.email_service import get_email_service
|
||||
|
||||
return cls(
|
||||
user_store=user_store,
|
||||
email_service=get_email_service(),
|
||||
session_expiry_hours=config.SESSION_EXPIRY_HOURS,
|
||||
require_email_verification=config.REQUIRE_EMAIL_VERIFICATION,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Registration
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def register(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
email: Optional[str] = None,
|
||||
guest_id: Optional[str] = None,
|
||||
) -> RegistrationResult:
|
||||
"""
|
||||
Register a new user account.
|
||||
|
||||
Args:
|
||||
username: Desired username.
|
||||
password: Plain text password.
|
||||
email: Optional email address.
|
||||
guest_id: Guest session ID if converting.
|
||||
|
||||
Returns:
|
||||
RegistrationResult with user or error.
|
||||
"""
|
||||
# Validate inputs
|
||||
if len(username) < 2 or len(username) > 50:
|
||||
return RegistrationResult(success=False, error="Username must be 2-50 characters")
|
||||
|
||||
if len(password) < 8:
|
||||
return RegistrationResult(success=False, error="Password must be at least 8 characters")
|
||||
|
||||
# Check for existing username
|
||||
existing = await self.user_store.get_user_by_username(username)
|
||||
if existing:
|
||||
return RegistrationResult(success=False, error="Username already taken")
|
||||
|
||||
# Check for existing email
|
||||
if email:
|
||||
existing = await self.user_store.get_user_by_email(email)
|
||||
if existing:
|
||||
return RegistrationResult(success=False, error="Email already registered")
|
||||
|
||||
# Hash password
|
||||
password_hash = self._hash_password(password)
|
||||
|
||||
# Generate verification token if needed
|
||||
verification_token = None
|
||||
verification_expires = None
|
||||
if email and self.require_email_verification:
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
|
||||
# Create user
|
||||
user = await self.user_store.create_user(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
email=email,
|
||||
role=UserRole.USER,
|
||||
guest_id=guest_id,
|
||||
verification_token=verification_token,
|
||||
verification_expires=verification_expires,
|
||||
)
|
||||
|
||||
if not user:
|
||||
return RegistrationResult(success=False, error="Failed to create account")
|
||||
|
||||
# Mark guest as converted if applicable
|
||||
if guest_id:
|
||||
await self.user_store.mark_guest_converted(guest_id, user.id)
|
||||
|
||||
# Send verification email if needed
|
||||
requires_verification = False
|
||||
if email and self.require_email_verification and verification_token:
|
||||
await self.email_service.send_verification_email(
|
||||
to=email,
|
||||
token=verification_token,
|
||||
username=username,
|
||||
)
|
||||
await self.user_store.log_email(user.id, "verification", email)
|
||||
requires_verification = True
|
||||
|
||||
return RegistrationResult(
|
||||
success=True,
|
||||
user=user,
|
||||
requires_verification=requires_verification,
|
||||
)
|
||||
|
||||
async def verify_email(self, token: str) -> AuthResult:
|
||||
"""
|
||||
Verify email with token.
|
||||
|
||||
Args:
|
||||
token: Verification token from email.
|
||||
|
||||
Returns:
|
||||
AuthResult with success status.
|
||||
"""
|
||||
user = await self.user_store.get_user_by_verification_token(token)
|
||||
if not user:
|
||||
return AuthResult(success=False, error="Invalid verification token")
|
||||
|
||||
# Check expiration
|
||||
if user.verification_expires and user.verification_expires < datetime.now(timezone.utc):
|
||||
return AuthResult(success=False, error="Verification token expired")
|
||||
|
||||
# Mark as verified
|
||||
await self.user_store.clear_verification_token(user.id)
|
||||
|
||||
# Refresh user
|
||||
user = await self.user_store.get_user_by_id(user.id)
|
||||
|
||||
return AuthResult(success=True, user=user)
|
||||
|
||||
async def resend_verification(self, email: str) -> bool:
|
||||
"""
|
||||
Resend verification email.
|
||||
|
||||
Args:
|
||||
email: Email address to send to.
|
||||
|
||||
Returns:
|
||||
True if email was sent.
|
||||
"""
|
||||
user = await self.user_store.get_user_by_email(email)
|
||||
if not user or user.email_verified:
|
||||
return False
|
||||
|
||||
# Generate new token
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
|
||||
await self.user_store.update_user(
|
||||
user.id,
|
||||
verification_token=verification_token,
|
||||
verification_expires=verification_expires,
|
||||
)
|
||||
|
||||
await self.email_service.send_verification_email(
|
||||
to=email,
|
||||
token=verification_token,
|
||||
username=user.username,
|
||||
)
|
||||
await self.user_store.log_email(user.id, "verification", email)
|
||||
|
||||
return True
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Login/Logout
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def login(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
device_info: Optional[dict] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuthResult:
|
||||
"""
|
||||
Authenticate user and create session.
|
||||
|
||||
Args:
|
||||
username: Username or email.
|
||||
password: Plain text password.
|
||||
device_info: Client device information.
|
||||
ip_address: Client IP address.
|
||||
|
||||
Returns:
|
||||
AuthResult with session token or error.
|
||||
"""
|
||||
# Try username first, then email
|
||||
user = await self.user_store.get_user_by_username(username)
|
||||
if not user:
|
||||
user = await self.user_store.get_user_by_email(username)
|
||||
|
||||
if not user:
|
||||
return AuthResult(success=False, error="Invalid credentials")
|
||||
|
||||
if not user.can_login():
|
||||
return AuthResult(success=False, error="Account is disabled")
|
||||
|
||||
# Check email verification if required
|
||||
if self.require_email_verification and user.email and not user.email_verified:
|
||||
return AuthResult(success=False, error="Please verify your email first")
|
||||
|
||||
# Verify password
|
||||
if not self._verify_password(password, user.password_hash):
|
||||
return AuthResult(success=False, error="Invalid credentials")
|
||||
|
||||
# Create session
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=self.session_expiry_hours)
|
||||
|
||||
await self.user_store.create_session(
|
||||
user_id=user.id,
|
||||
token=token,
|
||||
expires_at=expires_at,
|
||||
device_info=device_info,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
# Update last login
|
||||
await self.user_store.update_user(user.id, last_login=datetime.now(timezone.utc))
|
||||
|
||||
return AuthResult(
|
||||
success=True,
|
||||
user=user,
|
||||
token=token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
async def logout(self, token: str) -> bool:
|
||||
"""
|
||||
Invalidate a session.
|
||||
|
||||
Args:
|
||||
token: Session token to invalidate.
|
||||
|
||||
Returns:
|
||||
True if session was revoked.
|
||||
"""
|
||||
return await self.user_store.revoke_session_by_token(token)
|
||||
|
||||
async def logout_all(self, user_id: str, except_token: Optional[str] = None) -> int:
|
||||
"""
|
||||
Invalidate all sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID.
|
||||
except_token: Optional token to keep active.
|
||||
|
||||
Returns:
|
||||
Number of sessions revoked.
|
||||
"""
|
||||
return await self.user_store.revoke_all_sessions(user_id, except_token)
|
||||
|
||||
async def get_user_from_token(self, token: str) -> Optional[User]:
|
||||
"""
|
||||
Get user from session token.
|
||||
|
||||
Args:
|
||||
token: Session token.
|
||||
|
||||
Returns:
|
||||
User if valid session, None otherwise.
|
||||
"""
|
||||
session = await self.user_store.get_session_by_token(token)
|
||||
if not session or not session.is_valid():
|
||||
return None
|
||||
|
||||
# Update last used
|
||||
await self.user_store.update_session_last_used(session.id)
|
||||
|
||||
user = await self.user_store.get_user_by_id(session.user_id)
|
||||
if not user or not user.can_login():
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Password Management
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def forgot_password(self, email: str) -> bool:
|
||||
"""
|
||||
Initiate password reset flow.
|
||||
|
||||
Args:
|
||||
email: Email address.
|
||||
|
||||
Returns:
|
||||
True if reset email was sent (always returns True to prevent enumeration).
|
||||
"""
|
||||
user = await self.user_store.get_user_by_email(email)
|
||||
if not user:
|
||||
# Don't reveal if email exists
|
||||
return True
|
||||
|
||||
# Generate reset token
|
||||
reset_token = secrets.token_urlsafe(32)
|
||||
reset_expires = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
|
||||
await self.user_store.update_user(
|
||||
user.id,
|
||||
reset_token=reset_token,
|
||||
reset_expires=reset_expires,
|
||||
)
|
||||
|
||||
await self.email_service.send_password_reset_email(
|
||||
to=email,
|
||||
token=reset_token,
|
||||
username=user.username,
|
||||
)
|
||||
await self.user_store.log_email(user.id, "password_reset", email)
|
||||
|
||||
return True
|
||||
|
||||
async def reset_password(self, token: str, new_password: str) -> AuthResult:
|
||||
"""
|
||||
Reset password using token.
|
||||
|
||||
Args:
|
||||
token: Reset token from email.
|
||||
new_password: New password.
|
||||
|
||||
Returns:
|
||||
AuthResult with success status.
|
||||
"""
|
||||
if len(new_password) < 8:
|
||||
return AuthResult(success=False, error="Password must be at least 8 characters")
|
||||
|
||||
user = await self.user_store.get_user_by_reset_token(token)
|
||||
if not user:
|
||||
return AuthResult(success=False, error="Invalid reset token")
|
||||
|
||||
# Check expiration
|
||||
if user.reset_expires and user.reset_expires < datetime.now(timezone.utc):
|
||||
return AuthResult(success=False, error="Reset token expired")
|
||||
|
||||
# Update password
|
||||
password_hash = self._hash_password(new_password)
|
||||
await self.user_store.update_user(user.id, password_hash=password_hash)
|
||||
await self.user_store.clear_reset_token(user.id)
|
||||
|
||||
# Revoke all sessions
|
||||
await self.user_store.revoke_all_sessions(user.id)
|
||||
|
||||
# Send notification
|
||||
if user.email:
|
||||
await self.email_service.send_password_changed_notification(
|
||||
to=user.email,
|
||||
username=user.username,
|
||||
)
|
||||
await self.user_store.log_email(user.id, "password_changed", user.email)
|
||||
|
||||
return AuthResult(success=True, user=user)
|
||||
|
||||
async def change_password(
|
||||
self,
|
||||
user_id: str,
|
||||
current_password: str,
|
||||
new_password: str,
|
||||
current_token: Optional[str] = None,
|
||||
) -> AuthResult:
|
||||
"""
|
||||
Change password for authenticated user.
|
||||
|
||||
Args:
|
||||
user_id: User ID.
|
||||
current_password: Current password for verification.
|
||||
new_password: New password.
|
||||
current_token: Current session token to keep active.
|
||||
|
||||
Returns:
|
||||
AuthResult with success status.
|
||||
"""
|
||||
if len(new_password) < 8:
|
||||
return AuthResult(success=False, error="Password must be at least 8 characters")
|
||||
|
||||
user = await self.user_store.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return AuthResult(success=False, error="User not found")
|
||||
|
||||
# Verify current password
|
||||
if not self._verify_password(current_password, user.password_hash):
|
||||
return AuthResult(success=False, error="Current password is incorrect")
|
||||
|
||||
# Update password
|
||||
password_hash = self._hash_password(new_password)
|
||||
await self.user_store.update_user(user.id, password_hash=password_hash)
|
||||
|
||||
# Revoke all sessions except current
|
||||
await self.user_store.revoke_all_sessions(user.id, except_token=current_token)
|
||||
|
||||
# Send notification
|
||||
if user.email:
|
||||
await self.email_service.send_password_changed_notification(
|
||||
to=user.email,
|
||||
username=user.username,
|
||||
)
|
||||
await self.user_store.log_email(user.id, "password_changed", user.email)
|
||||
|
||||
return AuthResult(success=True, user=user)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# User Profile
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def update_preferences(self, user_id: str, preferences: dict) -> Optional[User]:
|
||||
"""
|
||||
Update user preferences.
|
||||
|
||||
Args:
|
||||
user_id: User ID.
|
||||
preferences: New preferences dict.
|
||||
|
||||
Returns:
|
||||
Updated user or None.
|
||||
"""
|
||||
return await self.user_store.update_user(user_id, preferences=preferences)
|
||||
|
||||
async def get_sessions(self, user_id: str) -> list[UserSession]:
|
||||
"""
|
||||
Get all active sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID.
|
||||
|
||||
Returns:
|
||||
List of active sessions.
|
||||
"""
|
||||
return await self.user_store.get_sessions_for_user(user_id)
|
||||
|
||||
async def revoke_session(self, user_id: str, session_id: str) -> bool:
|
||||
"""
|
||||
Revoke a specific session.
|
||||
|
||||
Args:
|
||||
user_id: User ID (for authorization).
|
||||
session_id: Session ID to revoke.
|
||||
|
||||
Returns:
|
||||
True if session was revoked.
|
||||
"""
|
||||
# Verify session belongs to user
|
||||
sessions = await self.user_store.get_sessions_for_user(user_id)
|
||||
if not any(s.id == session_id for s in sessions):
|
||||
return False
|
||||
|
||||
return await self.user_store.revoke_session(session_id)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Guest Conversion
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def convert_guest(
|
||||
self,
|
||||
guest_id: str,
|
||||
username: str,
|
||||
password: str,
|
||||
email: Optional[str] = None,
|
||||
) -> RegistrationResult:
|
||||
"""
|
||||
Convert guest session to full user account.
|
||||
|
||||
Args:
|
||||
guest_id: Guest session ID.
|
||||
username: Desired username.
|
||||
password: Password.
|
||||
email: Optional email.
|
||||
|
||||
Returns:
|
||||
RegistrationResult with user or error.
|
||||
"""
|
||||
# Verify guest exists and not already converted
|
||||
guest = await self.user_store.get_guest_session(guest_id)
|
||||
if not guest:
|
||||
return RegistrationResult(success=False, error="Guest session not found")
|
||||
|
||||
if guest.is_converted():
|
||||
return RegistrationResult(success=False, error="Guest already converted")
|
||||
|
||||
# Register with guest ID
|
||||
return await self.register(
|
||||
username=username,
|
||||
password=password,
|
||||
email=email,
|
||||
guest_id=guest_id,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Account Deletion
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def delete_account(self, user_id: str) -> bool:
|
||||
"""
|
||||
Soft delete user account.
|
||||
|
||||
Args:
|
||||
user_id: User ID to delete.
|
||||
|
||||
Returns:
|
||||
True if account was deleted.
|
||||
"""
|
||||
# Revoke all sessions
|
||||
await self.user_store.revoke_all_sessions(user_id)
|
||||
|
||||
# Soft delete
|
||||
user = await self.user_store.update_user(
|
||||
user_id,
|
||||
is_active=False,
|
||||
deleted_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
return user is not None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Guest Sessions
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def create_guest_session(
|
||||
self,
|
||||
guest_id: str,
|
||||
display_name: Optional[str] = None,
|
||||
) -> GuestSession:
|
||||
"""
|
||||
Create or get guest session.
|
||||
|
||||
Args:
|
||||
guest_id: Guest session ID.
|
||||
display_name: Display name for guest.
|
||||
|
||||
Returns:
|
||||
GuestSession.
|
||||
"""
|
||||
existing = await self.user_store.get_guest_session(guest_id)
|
||||
if existing:
|
||||
await self.user_store.update_guest_last_seen(guest_id)
|
||||
return existing
|
||||
|
||||
return await self.user_store.create_guest_session(guest_id, display_name)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Password Hashing
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _hash_password(self, password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password.encode(), salt)
|
||||
return hashed.decode()
|
||||
|
||||
def _verify_password(self, password: str, password_hash: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Global auth service instance
|
||||
_auth_service: Optional[AuthService] = None
|
||||
|
||||
|
||||
async def get_auth_service(user_store: UserStore) -> AuthService:
|
||||
"""
|
||||
Get or create the global auth service instance.
|
||||
|
||||
Args:
|
||||
user_store: User persistence store.
|
||||
|
||||
Returns:
|
||||
AuthService instance.
|
||||
"""
|
||||
global _auth_service
|
||||
if _auth_service is None:
|
||||
_auth_service = await AuthService.create(user_store)
|
||||
return _auth_service
|
||||
|
||||
|
||||
async def close_auth_service() -> None:
|
||||
"""Close the global auth service."""
|
||||
global _auth_service
|
||||
_auth_service = None
|
||||
Reference in New Issue
Block a user