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:
@@ -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()
|
||||
|
||||
|
||||
5
src/decorators/__init__.py
Normal file
5
src/decorators/__init__.py
Normal 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
28
src/decorators/auth.py
Normal 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
|
||||
@@ -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
39
src/forms/login.py
Normal 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,
|
||||
)
|
||||
@@ -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
42
src/models/rate_limit.py
Normal 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)>"
|
||||
@@ -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.
|
||||
|
||||
|
||||
59
src/templates/admin/login.html
Normal file
59
src/templates/admin/login.html
Normal 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
9
src/utils/__init__.py
Normal 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
84
src/utils/rate_limit.py
Normal 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()
|
||||
Reference in New Issue
Block a user