test
This commit is contained in:
+79
-69
@@ -1,10 +1,4 @@
|
||||
"""Authentication routes and login-related helper functions.
|
||||
|
||||
This module handles user registration, login, logout, password changes,
|
||||
login-attempt throttling, and authenticator-app based two-factor
|
||||
authentication. It also contains the utility functions that keep these flows
|
||||
consistent across the different route handlers.
|
||||
"""
|
||||
"""Authentication routes and security helpers for account access."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
@@ -24,10 +18,11 @@ from .. import db
|
||||
from ..models.login_attempt import LoginAttempt
|
||||
from ..models.user import User
|
||||
|
||||
# Blueprint that groups all authentication and account-security routes.
|
||||
# The blueprint keeps all account-related routes in one security-focused module.
|
||||
login_bp = Blueprint("login", __name__)
|
||||
|
||||
# constants
|
||||
# Central limits are defined once so registration, login, and settings all
|
||||
# enforce the same rules and do not drift apart over time.
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 128
|
||||
MAX_LOGIN_FAILURES = 4
|
||||
@@ -41,13 +36,16 @@ USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{3,20}$")
|
||||
DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protection")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# General helper functions
|
||||
|
||||
def utcnow():
|
||||
"""Return the current UTC time as datetime."""
|
||||
"""Return the current UTC time for security-related comparisons."""
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def get_utc_timestamp(moment=None):
|
||||
"""Convert a datetime into a Unix timestamp in UTC seconds."""
|
||||
"""Convert a datetime into UTC seconds for time-based calculations."""
|
||||
if moment is None:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
@@ -58,19 +56,50 @@ def get_utc_timestamp(moment=None):
|
||||
|
||||
|
||||
def normalize_username(username):
|
||||
"""Trim surrounding whitespace from the username field."""
|
||||
"""Normalize usernames so harmless whitespace does not affect login or signup."""
|
||||
return (username or "").strip()
|
||||
|
||||
|
||||
def lookup_user_by_username(username):
|
||||
"""Find usernames case-insensitively to keep login user-friendly."""
|
||||
if not username:
|
||||
return None
|
||||
|
||||
return User.query.filter(db.func.lower(User.username) == username.lower()).first()
|
||||
|
||||
|
||||
def minutes_until(target_time):
|
||||
"""Round remaining lock time up so the feedback stays conservative."""
|
||||
seconds = max(1, int((target_time - utcnow()).total_seconds()))
|
||||
return max(1, (seconds + 59) // 60)
|
||||
|
||||
|
||||
def render_login_settings(error=None):
|
||||
"""Render login settings with the current 2FA setup state included."""
|
||||
pending_secret = get_pending_two_factor_setup_secret(current_user)
|
||||
formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None
|
||||
provisioning_uri = build_totp_uri(current_user, pending_secret) if pending_secret else None
|
||||
|
||||
return render_template(
|
||||
"login_settings.html",
|
||||
error=error,
|
||||
pending_two_factor_secret=formatted_secret,
|
||||
pending_two_factor_uri=provisioning_uri,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 1. Password enforcement
|
||||
|
||||
def validate_username(username):
|
||||
"""Return an error message if the username violates the allowed pattern."""
|
||||
"""Reject usernames that would not fit the project's account policy."""
|
||||
if not USERNAME_PATTERN.fullmatch(username):
|
||||
return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores."
|
||||
return None
|
||||
|
||||
|
||||
def validate_password(password):
|
||||
"""Return an error message if the password length is outside the allowed range."""
|
||||
"""Reject passwords that fall outside the shared account policy."""
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long."
|
||||
|
||||
@@ -80,8 +109,11 @@ def validate_password(password):
|
||||
return None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 2. Anti brute force script
|
||||
|
||||
def get_request_ip():
|
||||
"""Determine the client IP address, preferring a reverse-proxy header when present."""
|
||||
"""Identify the request source so repeated failed attempts can be throttled."""
|
||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||
|
||||
if forwarded_for:
|
||||
@@ -91,7 +123,7 @@ def get_request_ip():
|
||||
|
||||
|
||||
def get_or_create_login_attempt():
|
||||
"""Fetch or create the failed-login counter for the current IP address."""
|
||||
"""Persist failed-attempt state so throttling survives across requests."""
|
||||
ip_address = get_request_ip()
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address).first()
|
||||
|
||||
@@ -103,14 +135,14 @@ def get_or_create_login_attempt():
|
||||
|
||||
|
||||
def clear_expired_lock(attempt, now):
|
||||
"""Reset the failure counter when a temporary lockout has expired."""
|
||||
"""Remove stale lockouts so temporary blocks do not outlive their purpose."""
|
||||
if attempt.locked_until and attempt.locked_until <= now:
|
||||
attempt.failed_attempts = 0
|
||||
attempt.locked_until = None
|
||||
|
||||
|
||||
def get_active_lock():
|
||||
"""Return the active lockout time for the current IP address."""
|
||||
"""Check whether the current IP is still inside a lockout window."""
|
||||
now = utcnow()
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first()
|
||||
|
||||
@@ -118,13 +150,12 @@ def get_active_lock():
|
||||
return None
|
||||
|
||||
clear_expired_lock(attempt, now)
|
||||
|
||||
db.session.commit()
|
||||
return attempt.locked_until
|
||||
|
||||
|
||||
def record_failed_login():
|
||||
"""Increase the failed-login counter for this IP and lock it if needed."""
|
||||
"""Increase the failure counter so repeated guessing becomes expensive."""
|
||||
now = utcnow()
|
||||
attempt = get_or_create_login_attempt()
|
||||
clear_expired_lock(attempt, now)
|
||||
@@ -139,7 +170,7 @@ def record_failed_login():
|
||||
|
||||
|
||||
def clear_login_attempts():
|
||||
"""Remove failed-login attempts for this IP after a successful login."""
|
||||
"""Reset the throttle state after a legitimate login succeeds."""
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first()
|
||||
|
||||
if attempt:
|
||||
@@ -148,44 +179,33 @@ def clear_login_attempts():
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def lookup_user_by_username(username):
|
||||
"""Look up a user case-insensitively so login is not case-sensitive."""
|
||||
if not username:
|
||||
return None
|
||||
|
||||
return User.query.filter(db.func.lower(User.username) == username.lower()).first()
|
||||
|
||||
|
||||
def minutes_until(target_time):
|
||||
"""Return the remaining lockout time in rounded-up whole minutes."""
|
||||
seconds = max(1, int((target_time - utcnow()).total_seconds()))
|
||||
return max(1, (seconds + 59) // 60)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3. 2-factor authentication
|
||||
|
||||
def normalize_two_factor_secret(secret):
|
||||
"""Normalize a Base32 secret by removing spaces and uppercasing it."""
|
||||
"""Normalize shared secrets so formatting differences do not break validation."""
|
||||
return (secret or "").strip().replace(" ", "").upper()
|
||||
|
||||
|
||||
def generate_two_factor_secret():
|
||||
"""Generate a new random Base32 secret for authenticator apps."""
|
||||
"""Generate a fresh secret so every account has its own 2FA seed."""
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def format_two_factor_secret(secret):
|
||||
"""Insert spaces into the secret to make manual entry easier."""
|
||||
"""Group the secret for humans because setup may happen by manual typing."""
|
||||
normalized = normalize_two_factor_secret(secret)
|
||||
return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4))
|
||||
|
||||
|
||||
def clear_pending_two_factor(user=None):
|
||||
"""Remove the session values used during the login 2FA verification step."""
|
||||
"""Clear unfinished login verification state from the session."""
|
||||
session.pop("pending_2fa_user_id", None)
|
||||
session.pop("pending_2fa_attempts", None)
|
||||
|
||||
|
||||
def get_pending_two_factor_setup_secret(user):
|
||||
"""Return the temporary secret generated during 2FA setup for this user."""
|
||||
"""Return only the setup secret that belongs to the current account."""
|
||||
if session.get("pending_2fa_setup_user_id") != user.id:
|
||||
return None
|
||||
|
||||
@@ -194,13 +214,13 @@ def get_pending_two_factor_setup_secret(user):
|
||||
|
||||
|
||||
def clear_pending_two_factor_setup():
|
||||
"""Remove the session values used during authenticator setup."""
|
||||
"""Remove unfinished authenticator setup state from the session."""
|
||||
session.pop("pending_2fa_setup_user_id", None)
|
||||
session.pop("pending_2fa_setup_secret", None)
|
||||
|
||||
|
||||
def build_totp_uri(user, secret):
|
||||
"""Build the otpauth URI consumed by authenticator applications."""
|
||||
"""Create the otpauth URI so authenticator apps can import the account."""
|
||||
normalized_secret = normalize_two_factor_secret(secret)
|
||||
label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}")
|
||||
issuer = quote(TWO_FACTOR_ISSUER)
|
||||
@@ -212,14 +232,14 @@ def build_totp_uri(user, secret):
|
||||
|
||||
|
||||
def decode_two_factor_secret(secret):
|
||||
"""Decode the Base32 secret into raw bytes for HMAC processing."""
|
||||
"""Decode the stored Base32 secret into raw bytes for code generation."""
|
||||
normalized_secret = normalize_two_factor_secret(secret)
|
||||
padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8))
|
||||
return base64.b32decode(padded_secret, casefold=True)
|
||||
|
||||
|
||||
def generate_totp_code(secret, for_time=None):
|
||||
"""Generate a time-based one-time password from the shared secret."""
|
||||
"""Generate a code locally so the app can verify authenticator input itself."""
|
||||
timestamp = get_utc_timestamp(for_time)
|
||||
counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS
|
||||
key = decode_two_factor_secret(secret)
|
||||
@@ -232,7 +252,7 @@ def generate_totp_code(secret, for_time=None):
|
||||
|
||||
|
||||
def verify_totp_code(secret, code, at_time=None):
|
||||
"""Validate a 2FA code, allowing a small time window for clock drift."""
|
||||
"""Allow a small time drift so valid users are not locked out unnecessarily."""
|
||||
normalized_code = (code or "").strip()
|
||||
|
||||
if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS:
|
||||
@@ -253,7 +273,7 @@ def verify_totp_code(secret, code, at_time=None):
|
||||
|
||||
|
||||
def get_pending_two_factor_user():
|
||||
"""Load the user who is currently in the middle of the login 2FA step."""
|
||||
"""Load the account that is currently waiting for login verification."""
|
||||
user_id = session.get("pending_2fa_user_id")
|
||||
|
||||
if not user_id:
|
||||
@@ -262,23 +282,12 @@ def get_pending_two_factor_user():
|
||||
return db.session.get(User, user_id)
|
||||
|
||||
|
||||
def render_login_settings(error=None):
|
||||
"""Render the login settings page with optional 2FA setup data."""
|
||||
pending_secret = get_pending_two_factor_setup_secret(current_user)
|
||||
formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None
|
||||
provisioning_uri = build_totp_uri(current_user, pending_secret) if pending_secret else None
|
||||
|
||||
return render_template(
|
||||
"login_settings.html",
|
||||
error=error,
|
||||
pending_two_factor_secret=formatted_secret,
|
||||
pending_two_factor_uri=provisioning_uri,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Routes
|
||||
|
||||
@login_bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
"""Create a new user account after validating the submitted credentials."""
|
||||
"""Create a user account after validating the configured account rules."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
@@ -298,6 +307,7 @@ def register():
|
||||
if lookup_user_by_username(username):
|
||||
return render_template("sign_up.html", error="Username already taken!", username=username)
|
||||
|
||||
# Passwords are stored hashed so a leaked database does not reveal them directly.
|
||||
new_user = User(username=username, password=generate_password_hash(password))
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
@@ -310,7 +320,7 @@ def register():
|
||||
|
||||
@login_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Authenticate a user and start the 2FA step when required."""
|
||||
"""Authenticate a user and branch into 2FA only when required."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
@@ -321,7 +331,7 @@ def login():
|
||||
if not username or not password:
|
||||
return render_template("login.html", error="Username and password are required.", username=username)
|
||||
|
||||
# Stop early if this IP address is currently locked.
|
||||
# Locked IPs are rejected early so the expensive password check is not repeated.
|
||||
locked_until = get_active_lock()
|
||||
|
||||
if locked_until and locked_until > utcnow():
|
||||
@@ -332,8 +342,8 @@ def login():
|
||||
)
|
||||
|
||||
user = lookup_user_by_username(username)
|
||||
# Use a dummy hash when the user does not exist so the timing stays
|
||||
# similar and does not reveal valid usernames.
|
||||
|
||||
# A dummy hash keeps timing similar even when the username does not exist.
|
||||
password_hash = user.password if user else DUMMY_PASSWORD_HASH
|
||||
password_matches = check_password_hash(password_hash, password)
|
||||
|
||||
@@ -351,7 +361,7 @@ def login():
|
||||
clear_login_attempts()
|
||||
|
||||
if user.two_factor_enabled:
|
||||
# Delay the full login until the authenticator code has been verified.
|
||||
# The actual login is delayed until the second factor proves account ownership.
|
||||
if not user.two_factor_secret:
|
||||
return render_template(
|
||||
"login.html",
|
||||
@@ -376,7 +386,7 @@ def login():
|
||||
|
||||
@login_bp.route("/login/verify", methods=["GET", "POST"])
|
||||
def verify_two_factor():
|
||||
"""Complete login by verifying the authenticator-app code."""
|
||||
"""Finish login by validating the authenticator app code."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
@@ -397,7 +407,7 @@ def verify_two_factor():
|
||||
|
||||
attempts = int(session.get("pending_2fa_attempts", 0))
|
||||
|
||||
# Limit repeated guesses during the second factor step as well.
|
||||
# The second factor gets its own retry limit so guessing stays impractical.
|
||||
if attempts >= MAX_TWO_FACTOR_ATTEMPTS:
|
||||
clear_pending_two_factor(user)
|
||||
db.session.commit()
|
||||
@@ -447,8 +457,7 @@ def login_settings():
|
||||
if current_user.two_factor_enabled:
|
||||
return render_login_settings(error="Authenticator app 2FA is already enabled.")
|
||||
|
||||
# Store the generated secret in the session until the user confirms
|
||||
# it with a valid authenticator code.
|
||||
# The secret stays temporary until the user proves the app was configured correctly.
|
||||
secret = generate_two_factor_secret()
|
||||
session["pending_2fa_setup_user_id"] = current_user.id
|
||||
session["pending_2fa_setup_secret"] = secret
|
||||
@@ -491,6 +500,7 @@ def login_settings():
|
||||
new_password = request.form.get("new_password") or ""
|
||||
confirm_password = request.form.get("confirm_password") or ""
|
||||
|
||||
# The old password is checked first so nobody can change credentials from a stolen session alone.
|
||||
if not check_password_hash(current_user.password, current_password):
|
||||
return render_login_settings(error="Your current password is incorrect.")
|
||||
|
||||
@@ -515,7 +525,7 @@ def login_settings():
|
||||
@login_bp.route("/logout", methods=["POST"])
|
||||
@login_required
|
||||
def logout():
|
||||
"""Log the user out and clear any unfinished authentication state."""
|
||||
"""Log the user out and clear unfinished security state."""
|
||||
clear_pending_two_factor()
|
||||
clear_pending_two_factor_setup()
|
||||
logout_user()
|
||||
|
||||
@@ -32,6 +32,8 @@ def build_board_from_positions(piece_positions, board_size):
|
||||
"""Convert the legacy one-piece-per-row format into a board matrix."""
|
||||
board = create_empty_board(board_size)
|
||||
|
||||
# Older save points may only store one column per row, so they are rebuilt
|
||||
# here to keep the rest of the code on one consistent board format.
|
||||
for row, col in enumerate(piece_positions):
|
||||
if isinstance(col, int) and 0 <= col < board_size:
|
||||
board[row][col] = 1
|
||||
@@ -47,6 +49,8 @@ def normalize_board(candidate_board, board_size):
|
||||
if all(isinstance(row, list) and len(row) == board_size for row in candidate_board):
|
||||
normalized_board = create_empty_board(board_size)
|
||||
|
||||
# Values are normalized to 0 or 1 so saved data stays tolerant toward
|
||||
# older or slightly different truthy formats.
|
||||
for row_index, row in enumerate(candidate_board):
|
||||
for col_index, value in enumerate(row):
|
||||
normalized_board[row_index][col_index] = 1 if value else 0
|
||||
@@ -120,6 +124,7 @@ def solve_queens(board_size):
|
||||
def place_queen(row):
|
||||
"""Backtracking search that fills the board row by row."""
|
||||
if row == board_size:
|
||||
# A copy is stored because the same list is reused while backtracking.
|
||||
queen_solutions.append(queen_positions[:])
|
||||
return
|
||||
|
||||
@@ -136,6 +141,9 @@ def solve_queens(board_size):
|
||||
def solve_rooks(board_size):
|
||||
"""Return one valid rook arrangement with exactly one rook per row/column."""
|
||||
rook_positions = list(range(board_size))
|
||||
|
||||
# Randomizing the order keeps rook mode from always returning the same
|
||||
# visually predictable arrangement.
|
||||
random.shuffle(rook_positions)
|
||||
return rook_positions
|
||||
|
||||
@@ -251,6 +259,8 @@ def serialize_game_state(game_state):
|
||||
"""Convert a database model into JSON-friendly data for the frontend."""
|
||||
board = normalize_board(json.loads(game_state.board), game_state.board_size)
|
||||
|
||||
# The frontend receives ready-to-render metadata here so it does not need
|
||||
# to re-derive common values from raw database fields.
|
||||
return {
|
||||
"id": game_state.id,
|
||||
"board": board,
|
||||
@@ -294,6 +304,7 @@ def build_save_point_query():
|
||||
"favorites": GameState.is_favorite.desc()
|
||||
}
|
||||
|
||||
# Sorting is centralized here so list views and reloads stay consistent.
|
||||
return query.order_by(sort_options.get(sort_by, GameState.created_at.desc()), GameState.id.desc())
|
||||
|
||||
|
||||
@@ -308,6 +319,9 @@ def home():
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Render the logged-in dashboard."""
|
||||
|
||||
# The flag is consumed once so only the immediate next dashboard load starts
|
||||
# from the default state after a fresh login.
|
||||
force_default_dashboard_mode = bool(session.pop("start_dashboard_with_default_mode", False))
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
@@ -342,6 +356,8 @@ def check():
|
||||
if current_row == row and current_col == col:
|
||||
continue
|
||||
|
||||
# The same conflict rules are shared between manual placement and the
|
||||
# solver so the UI behaves consistently with the backend.
|
||||
same_row = current_row == row
|
||||
same_column = current_col == col
|
||||
same_diagonal = abs(current_col - col) == abs(current_row - row)
|
||||
@@ -474,7 +490,7 @@ def load_game():
|
||||
.first()
|
||||
)
|
||||
|
||||
# If the user has never saved a game, return a clean default board.
|
||||
# Returning a default board keeps the dashboard usable even before the first save.
|
||||
if not latest_game:
|
||||
return jsonify({"board": create_empty_board(DEFAULT_BOARD_SIZE), "mode": "queens"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user