Skip to content

Authentication System

This document covers the authentication and authorization system implemented in the School Clubs Management System.

Overview

The application uses Flask-Login for session management combined with Werkzeug password hashing for secure authentication.

Key Features:

  • Email and password-based authentication
  • Secure password hashing (PBKDF2-SHA256)
  • Session-based login with "remember me" support
  • Role-based access control (students and admins)
  • Turbo Flask integration for dynamic UI updates

Architecture

Components

graph TD
    A[Login Request] --> B[auth.login_modal]
    B --> C{Valid Credentials?}
    C -->|Yes| D[login_user]
    D --> E[Redirect to Landing]
    C -->|No| F[Turbo Error Update]
    F --> G[Display Error]

    H[Protected Route] --> I{Authenticated?}
    I -->|No| J[@login_required]
    J --> K[Redirect to Landing]
    I -->|Yes| L[Check Permissions]
    L --> M{Authorized?}
    M -->|Yes| N[Allow Access]
    M -->|No| O[403 Forbidden]

Flask-Login Setup

Configuration in __init__.py:

from flask_login import LoginManager

login_manager = LoginManager()
login_manager.login_view = "auth.login_modal"
login_manager.init_app(app)

User Loader:

@login_manager.user_loader
def load_user(user_id):
    """
    Load user by ID from the database.

    Args:
        user_id: String user identifier

    Returns:
        User object or None if not found
    """
    return User.query.get(int(user_id))

Unauthorized Handler:

@login_manager.unauthorized_handler
def unauthorized():
    """
    Handle unauthorized access attempts.
    Redirects to landing page with warning message.
    """
    flash("You must be logged in to access this page.", "warning")
    return redirect(url_for("main.landing_page"))

User Model

The User model inherits from UserMixin which provides Flask-Login integration:

from flask_login import UserMixin

class User(UserMixin, db.Model):
    user_id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(80), unique=True, nullable=False)
    password_hash = db.Column(db.String(100), nullable=False)
    user_type = db.Column(db.String(10), nullable=False)  # 'student' or 'admin'

    def get_id(self):
        """Required by Flask-Login."""
        return str(self.user_id)

UserMixin Properties

Property Description
is_authenticated Returns True if user has valid credentials
is_active Returns True if account is active
is_anonymous Returns False for authenticated users
get_id() Returns unique user identifier

Authentication Routes

All authentication routes are defined in auth.py using a Blueprint.

Login

Route: POST /login_modal

Purpose: Process login form submission via Turbo Flask modal.

@auth.route("/login_modal", methods=["POST"])
def login_modal():
    """
    Handle login requests.

    Form Data:
        email: User's email address
        password: Plain text password

    Returns:
        Redirect to landing page on success
        204 No Content with Turbo update on failure
    """
    email = request.form.get("email")
    password = request.form.get("password")
    error = None

    user = User.query.filter_by(email=email).first()

    if user is None:
        error = "No account found with this email"
    elif not check_password_hash(user.password_hash, password):
        error = "Incorrect password"
    else:
        login_user(user)
        return redirect(url_for("main.landing_page"))

    # Show error via Turbo without page reload
    if error:
        turbo.push(turbo.update(
            render_template("error-login.html", error_user=error),
            target="login-error-container",
        ))
    return "", 204

Validation:

  1. Check if user exists by email
  2. Verify password hash matches
  3. Login user if valid
  4. Display error via Turbo if invalid

Turbo Flask Integration

Errors are displayed dynamically without page reloads by updating the login-error-container div.

Logout

Route: GET /logout

Purpose: End user session.

@auth.route("/logout")
@login_required
def logout():
    """
    Log out the current user.

    Returns:
        Redirect to landing page
    """
    logout_user()
    return redirect(url_for("main.landing_page"), 302)

Process:

  1. Clear session data
  2. Remove authentication cookies
  3. Redirect to landing page

Sign Up

Route: POST /sign_up

Purpose: Register new user accounts.

@auth.route("/sign_up", methods=["POST"])
def sign_up_post():
    """
    Handle new user registration.

    Form Data:
        first_name: User's first name
        last_name: User's last name
        email: Email address
        password: Plain text password

    Returns:
        Redirect to landing page on success
        204 with Turbo error update on failure
    """
    first_name = request.form.get("first_name")
    last_name = request.form.get("last_name")
    email = request.form.get("email")
    password = request.form.get("password")
    error = None

    # Validation
    if not all([first_name, last_name, email, password]):
        error = "Please fill in all fields"
    elif User.query.filter_by(email=email).first():
        error = "Email already registered"
    elif len(password) < 8:
        error = "Password must be at least 8 characters"

    # Create user if valid
    if error is None:
        hashed_pw = generate_password_hash(password)
        new_user = User(
            first_name=first_name,
            last_name=last_name,
            email=email,
            password_hash=hashed_pw,
            user_type="student",
        )
        db.session.add(new_user)
        db.session.commit()
        return redirect(url_for("main.landing_page"))

    # Show error via Turbo
    if error:
        turbo.push(turbo.update(
            render_template("error-login.html", error_user=error),
            target="signup-error-container",
        ))
    return "", 204

Validation Rules:

  • All fields required
  • Email must be unique
  • Password minimum 8 characters
  • New users default to "student" type

Password Security

Hashing

The application uses Werkzeug's generate_password_hash which implements PBKDF2-SHA256:

from werkzeug.security import generate_password_hash, check_password_hash

# Hashing (registration)
hashed = generate_password_hash(password)
# Result: pbkdf2:sha256:260000$salt$hash

# Verification (login)
is_valid = check_password_hash(stored_hash, password)

Security Features:

  • Algorithm: PBKDF2 with SHA-256
  • Iterations: 260,000+ (configurable)
  • Salt: Unique random salt per password
  • Length: 256-bit hash output

Best Practices

  • Never store plain text passwords
  • Never log passwords
  • Use strong hashing algorithms (PBKDF2, bcrypt, argon2)
  • Include unique salts per password

Password Requirements

Current requirements (implemented in sign_up_post):

  • Minimum 8 characters
  • No complexity requirements

Enhancement Opportunity

Consider adding:

import re

def validate_password(password):
    if len(password) < 8:
        return False, "Password must be at least 8 characters"
    if not re.search(r"[A-Z]", password):
        return False, "Password must contain uppercase letter"
    if not re.search(r"[a-z]", password):
        return False, "Password must contain lowercase letter"
    if not re.search(r"\d", password):
        return False, "Password must contain number"
    return True, ""


Session Management

Configuration

app.config["SECRET_KEY"] = "TDLLK"  # ⚠️ Use environment variable in production

Production Warning

Never hardcode secret keys. Use environment variables:

app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY") or secrets.token_hex(32)

Session Lifecycle

  1. Login: Create session with user ID
  2. Request: Load user via @login_manager.user_loader
  3. Logout: Clear session data

Current User

Access the authenticated user via current_user:

from flask_login import current_user

if current_user.is_authenticated:
    print(f"Logged in as {current_user.email}")
    print(f"User type: {current_user.user_type}")

Remember Me

Flask-Login supports "remember me" functionality:

# Login with remember me
login_user(user, remember=True)  # Cookie lasts for 365 days

# Custom duration
login_user(user, remember=True, duration=timedelta(days=30))

Currently not implemented in the application but can be added to the login form.


Authorization

Role-Based Access Control

The application implements role-based access with two user types:

Role Type Value Capabilities
Student student Create/edit own posts, join clubs, apply for membership
Administrator admin Full system access, manage all clubs and posts

Protection Decorators

@login_required

Ensures user is authenticated:

from flask_login import login_required

@main.route("/browse")
@login_required
def browse():
    # Only accessible to authenticated users
    return render_template("browse.html")

Custom Role Checks

The application implements utility functions for role checking:

Check Admin:

def is_admin(current_user):
    """
    Check if user is an administrator.

    Args:
        current_user: Flask-Login current_user proxy

    Returns:
        bool: True if user is admin
    """
    return current_user.user_type == "admin"

Check Club Member:

def is_member(current_user, club_id):
    """
    Check if user is a member, president, or admin.

    Args:
        current_user: Flask-Login current_user proxy
        club_id: Club identifier

    Returns:
        bool: True if user has member-level access
    """
    if current_user.user_type == "admin":
        return True
    return Membership.query.filter_by(
        student_id=current_user.user_id, 
        club_id=club_id
    ).first() is not None

Check Club President:

def is_president(current_user, club_id):
    """
    Check if user is president or admin.

    Args:
        current_user: Flask-Login current_user proxy
        club_id: Club identifier

    Returns:
        bool: True if user has president-level access
    """
    if current_user.user_type == "admin":
        return True
    membership = Membership.query.filter_by(
        student_id=current_user.user_id, 
        club_id=club_id
    ).first()
    return membership and membership.is_president

Usage in Routes

@main.route("/post/create", methods=["GET", "POST"])
@login_required
def create_post():
    club_id = request.form.get("club_id")

    # Check if user can post to this club
    if not is_member(current_user, club_id):
        flash("You must be a member to create posts.", "danger")
        return redirect(url_for("main.club_detail", club_id=club_id))

    # User is authorized, proceed with post creation
    # ...
@main.route("/remove/<int:club_id>/<int:user_id>", methods=["POST"])
@login_required
def remove_member(club_id, user_id):
    # Check president/admin access
    if not is_president(current_user, club_id):
        flash("Only presidents can remove members.", "danger")
        return redirect(url_for("main.club_detail", club_id=club_id))

    # User is authorized, proceed with member removal
    # ...

Admin Panel Access

Flask-Admin views use custom access control:

class AdminModelView(ModelView):
    """Base view with admin-only access."""

    def is_accessible(self):
        """Check if current user is an admin."""
        return current_user.is_authenticated and current_user.user_type == "admin"

    def inaccessible_callback(self, name, **kwargs):
        """Handle unauthorized access."""
        abort(403)

Security Best Practices

Implemented Protections

Password Hashing
All passwords hashed with PBKDF2-SHA256
Session Security
Sessions signed with SECRET_KEY
SQL Injection Prevention
SQLAlchemy ORM with parameterized queries
Access Control
Role-based authorization on all protected routes

Production Checklist

  • Use environment variables for SECRET_KEY
  • Implement rate limiting on login endpoint
  • Add CSRF protection with Flask-WTF
  • Enable HTTPS in production
  • Implement password reset functionality
  • Add email verification for new accounts
  • Log authentication attempts
  • Implement account lockout after failed attempts
  • Add two-factor authentication (2FA)

Rate Limiting Example

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    app,
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"]
)

@auth.route("/login_modal", methods=["POST"])
@limiter.limit("5 per minute")
def login_modal():
    # Login logic with rate limiting
    pass

CSRF Protection Example

from flask_wtf import FlaskForm, CSRFProtect

csrf = CSRFProtect(app)

# In templates
<form method="POST">
    {{ csrf_token() }}
    <!-- form fields -->
</form>

Testing Authentication

Test User Creation

from werkzeug.security import generate_password_hash

# Create test user
test_user = User(
    first_name="Test",
    last_name="User",
    email="test@example.com",
    password_hash=generate_password_hash("testpass123"),
    user_type="student"
)
db.session.add(test_user)
db.session.commit()

Test Login Flow

with app.test_client() as client:
    # Attempt login
    response = client.post("/login_modal", data={
        "email": "test@example.com",
        "password": "testpass123"
    })

    # Check if redirected to landing page
    assert response.status_code == 302
    assert response.location.endswith("/")

    # Verify user is logged in
    with client.session_transaction() as session:
        assert "_user_id" in session

Test Authorization

with app.test_client() as client:
    # Login as student
    client.post("/login_modal", data={
        "email": "student@example.com",
        "password": "password"
    })

    # Try to access admin route
    response = client.get("/admin/")
    assert response.status_code == 403  # Forbidden

Next Steps