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>
104 lines
3.1 KiB
Python
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")
|