feat: implement admin login

Implement Story 1.2 (Admin Login) with full TDD approach including:

- RateLimit model for tracking authentication attempts
- LoginForm for admin authentication with email, password, and remember_me fields
- Rate limiting utility functions (check, increment, reset)
- admin_required decorator for route protection
- Login route with rate limiting (5 attempts per 15 minutes)
- Logout route with session clearing
- Admin dashboard now requires authentication
- Login template with flash message support
- 14 comprehensive integration tests covering all acceptance criteria
- Email normalization to lowercase
- Session persistence with configurable duration (7 or 30 days)

All acceptance criteria met:
- Login form accepts email and password
- Invalid credentials show appropriate error message
- Successful login redirects to admin dashboard
- Session persists across browser refreshes
- Rate limiting after 5 failed attempts

Test coverage: 90.67% (exceeds 80% requirement)
All linting and type checking passes

Story: 1.2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-22 11:53:27 -07:00
co-authored by Claude
parent 6a2ac7a8a7
commit 6764455703
12 changed files with 716 additions and 4 deletions
+9
View File
@@ -0,0 +1,9 @@
"""Utility functions for Sneaky Klaus application."""
from src.utils.rate_limit import (
check_rate_limit,
increment_rate_limit,
reset_rate_limit,
)
__all__ = ["check_rate_limit", "increment_rate_limit", "reset_rate_limit"]
+84
View File
@@ -0,0 +1,84 @@
"""Rate limiting utilities for authentication."""
from datetime import datetime, timedelta
from src.app import db
from src.models import RateLimit
def check_rate_limit(key: str, max_attempts: int, window_minutes: int) -> bool:
"""Check if rate limit has been exceeded.
Args:
key: Rate limit key (e.g., "login:admin:user@example.com").
max_attempts: Maximum allowed attempts within window.
window_minutes: Time window in minutes.
Returns:
True if rate limit exceeded, False otherwise.
"""
rate_limit = db.session.query(RateLimit).filter_by(key=key).first()
if not rate_limit:
# No rate limit record exists yet
return False
now = datetime.utcnow()
# Check if rate limit window has expired
if rate_limit.expires_at <= now:
# Window expired, reset
rate_limit.attempts = 0
rate_limit.window_start = now
rate_limit.expires_at = now + timedelta(minutes=window_minutes)
db.session.commit()
return False
# Check if attempts exceeded
return bool(rate_limit.attempts >= max_attempts)
def increment_rate_limit(key: str, window_minutes: int) -> None:
"""Increment rate limit attempt counter.
Args:
key: Rate limit key (e.g., "login:admin:user@example.com").
window_minutes: Time window in minutes.
"""
rate_limit = db.session.query(RateLimit).filter_by(key=key).first()
now = datetime.utcnow()
if not rate_limit:
# Create new rate limit record
rate_limit = RateLimit(
key=key,
attempts=1,
window_start=now,
expires_at=now + timedelta(minutes=window_minutes),
)
db.session.add(rate_limit)
else:
# Check if window expired
if rate_limit.expires_at <= now:
# Reset window
rate_limit.attempts = 1
rate_limit.window_start = now
rate_limit.expires_at = now + timedelta(minutes=window_minutes)
else:
# Increment attempts
rate_limit.attempts += 1
db.session.commit()
def reset_rate_limit(key: str) -> None:
"""Reset rate limit counter for a key.
Args:
key: Rate limit key (e.g., "login:admin:user@example.com").
"""
rate_limit = db.session.query(RateLimit).filter_by(key=key).first()
if rate_limit:
rate_limit.attempts = 0
db.session.commit()