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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user