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
parent 6a2ac7a8a7
commit 6764455703
12 changed files with 716 additions and 4 deletions

View File

@@ -70,7 +70,7 @@ def create_app(config_name: str | None = None) -> Flask:
# Import models to ensure they're registered with SQLAlchemy
with app.app_context():
from src.models import Admin, Exchange # noqa: F401
from src.models import Admin, Exchange, RateLimit # noqa: F401
db.create_all()

View File

@@ -0,0 +1,5 @@
"""Decorators for Sneaky Klaus application."""
from src.decorators.auth import admin_required
__all__ = ["admin_required"]

28
src/decorators/auth.py Normal file
View File

@@ -0,0 +1,28 @@
"""Authentication decorators for route protection."""
from functools import wraps
from flask import flash, redirect, session, url_for
def admin_required(f):
"""Decorator to require admin authentication for a route.
Checks if user is logged in as admin. If not, redirects to login page
with appropriate flash message.
Args:
f: The function to decorate.
Returns:
Decorated function that checks authentication.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if "admin_id" not in session:
flash("You must be logged in as admin to access this page.", "error")
return redirect(url_for("admin.login"))
return f(*args, **kwargs)
return decorated_function

View File

@@ -1,5 +1,6 @@
"""Forms for Sneaky Klaus application."""
from src.forms.login import LoginForm
from src.forms.setup import SetupForm
__all__ = ["SetupForm"]
__all__ = ["LoginForm", "SetupForm"]

39
src/forms/login.py Normal file
View File

@@ -0,0 +1,39 @@
"""Login form for admin authentication."""
from flask_wtf import FlaskForm
from wtforms import BooleanField, EmailField, PasswordField
from wtforms.validators import DataRequired, Email
class LoginForm(FlaskForm):
"""Form for admin login.
Validates email format and requires password.
Attributes:
email: Email address for admin account.
password: Password for admin account.
remember_me: Whether to extend session duration.
"""
email = EmailField(
"Email Address",
validators=[
DataRequired(message="Email address is required."),
Email(message="Please enter a valid email address."),
],
render_kw={"placeholder": "admin@example.com"},
)
password = PasswordField(
"Password",
validators=[
DataRequired(message="Password is required."),
],
render_kw={"placeholder": "Enter your password"},
)
remember_me = BooleanField(
"Remember me",
default=False,
)

View File

@@ -5,5 +5,6 @@ This package contains all database models used by the application.
from src.models.admin import Admin
from src.models.exchange import Exchange
from src.models.rate_limit import RateLimit
__all__ = ["Admin", "Exchange"]
__all__ = ["Admin", "Exchange", "RateLimit"]

42
src/models/rate_limit.py Normal file
View File

@@ -0,0 +1,42 @@
"""Rate limiting model for Sneaky Klaus.
The RateLimit model tracks authentication attempts to prevent brute force attacks.
"""
from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from src.app import db
class RateLimit(db.Model): # type: ignore[name-defined]
"""Rate limiting for authentication attempts.
Tracks attempts per key (email/IP) within a time window
to prevent brute force attacks.
Attributes:
id: Auto-increment primary key.
key: Rate limit identifier (e.g., "login:admin:user@example.com").
attempts: Number of attempts in current window.
window_start: Start of current rate limit window.
expires_at: When rate limit resets.
"""
__tablename__ = "rate_limit"
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
window_start: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
def __repr__(self) -> str:
"""String representation of RateLimit instance."""
return f"<RateLimit {self.key} ({self.attempts} attempts)>"

View File

@@ -1,11 +1,99 @@
"""Admin routes for Sneaky Klaus application."""
from flask import Blueprint, render_template
from datetime import timedelta
from flask import Blueprint, flash, redirect, render_template, session, url_for
from src.app import bcrypt, db
from src.decorators import admin_required
from src.forms import LoginForm
from src.models import Admin
from src.utils import check_rate_limit, increment_rate_limit, reset_rate_limit
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
# Rate limiting constants
MAX_LOGIN_ATTEMPTS = 5
LOGIN_WINDOW_MINUTES = 15
@admin_bp.route("/login", methods=["GET", "POST"])
def login():
"""Handle admin login.
GET: Display login form.
POST: Process login credentials and create session.
Returns:
On GET: Rendered login form template.
On POST success: Redirect to admin dashboard.
On POST error: Re-render form with validation errors.
"""
# If already logged in, redirect to dashboard
if "admin_id" in session:
return redirect(url_for("admin.dashboard"))
form = LoginForm()
if form.validate_on_submit():
# Normalize email to lowercase
email = form.email.data.lower()
# Check rate limit
rate_limit_key = f"login:admin:{email}"
if check_rate_limit(rate_limit_key, MAX_LOGIN_ATTEMPTS, LOGIN_WINDOW_MINUTES):
flash("Too many login attempts. Please try again in 15 minutes.", "error")
return render_template("admin/login.html", form=form), 429
# Query admin by email
admin = db.session.query(Admin).filter_by(email=email).first()
# Verify credentials
if admin and bcrypt.check_password_hash(
admin.password_hash, form.password.data
):
# Reset rate limit on successful login
reset_rate_limit(rate_limit_key)
# Create session
session.clear()
session["admin_id"] = admin.id
session["admin_email"] = admin.email
session.permanent = True
# Set session duration based on remember_me
if form.remember_me.data:
session.permanent_session_lifetime = timedelta(days=30)
else:
session.permanent_session_lifetime = timedelta(days=7)
flash("Welcome back!", "success")
return redirect(url_for("admin.dashboard"))
else:
# Invalid credentials - increment rate limit
increment_rate_limit(rate_limit_key, LOGIN_WINDOW_MINUTES)
flash("Invalid email or password.", "error")
return render_template("admin/login.html", form=form)
@admin_bp.route("/logout", methods=["POST"])
@admin_required
def logout():
"""Handle admin logout.
POST: Clear session and redirect to login page.
Returns:
Redirect to login page.
"""
session.clear()
flash("You have been logged out.", "success")
return redirect(url_for("admin.login"))
@admin_bp.route("/dashboard")
@admin_required
def dashboard():
"""Display admin dashboard.

View File

@@ -0,0 +1,59 @@
{% extends "layouts/base.html" %}
{% block title %}Admin Login - Sneaky Klaus{% endblock %}
{% block content %}
<article style="max-width: 600px; margin: 4rem auto;">
<header>
<h1>Admin Login</h1>
<p>Sign in to manage your gift exchanges.</p>
</header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div role="alert" style="margin-bottom: 1rem; {% if category == 'error' %}color: var(--pico-form-element-invalid-border-color);{% else %}color: var(--pico-primary);{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('admin.login') }}">
{{ form.hidden_tag() }}
<div>
<label for="email">
{{ form.email.label.text }}
{{ form.email(required=True) }}
</label>
{% if form.email.errors %}
<small style="color: var(--pico-form-element-invalid-border-color);">
{{ form.email.errors[0] }}
</small>
{% endif %}
</div>
<div>
<label for="password">
{{ form.password.label.text }}
{{ form.password(required=True) }}
</label>
{% if form.password.errors %}
<small style="color: var(--pico-form-element-invalid-border-color);">
{{ form.password.errors[0] }}
</small>
{% endif %}
</div>
<div>
<label>
{{ form.remember_me() }}
{{ form.remember_me.label.text }}
</label>
</div>
<button type="submit">Login</button>
</form>
</article>
{% endblock %}

9
src/utils/__init__.py Normal file
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
src/utils/rate_limit.py Normal file
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()