Files
sneakyklaus/src/routes/admin.py
Phil Skentelbery 6764455703 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>
2025-12-22 11:53:27 -07:00

104 lines
3.1 KiB
Python

"""Admin routes for Sneaky Klaus application."""
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.
Returns:
Rendered admin dashboard template.
"""
return render_template("admin/dashboard.html")