+11
-2
@@ -16,8 +16,12 @@ db = SQLAlchemy()
|
|||||||
login_manager = LoginManager()
|
login_manager = LoginManager()
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app(config_overrides=None):
|
||||||
"""Build and configure the Flask application instance."""
|
"""Build and configure the Flask application instance.
|
||||||
|
|
||||||
|
Optional configuration overrides make it possible to run the same app
|
||||||
|
logic against an isolated test database without changing production code.
|
||||||
|
"""
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Build an absolute path to the application directory so the SQLite
|
# Build an absolute path to the application directory so the SQLite
|
||||||
@@ -32,6 +36,11 @@ def create_app():
|
|||||||
app.config["REMEMBER_COOKIE_HTTPONLY"] = True
|
app.config["REMEMBER_COOKIE_HTTPONLY"] = True
|
||||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||||
|
|
||||||
|
# Tests can inject their own database and runtime settings here so each run
|
||||||
|
# stays isolated from the real application data.
|
||||||
|
if config_overrides:
|
||||||
|
app.config.update(config_overrides)
|
||||||
|
|
||||||
# Connect the shared extension objects to this specific app instance.
|
# Connect the shared extension objects to this specific app instance.
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
"""Database models for persisted puzzle save points."""
|
||||||
|
|
||||||
from .. import db
|
from .. import db
|
||||||
|
|
||||||
|
|
||||||
class GameState(db.Model):
|
class GameState(db.Model):
|
||||||
|
"""Persist one saved puzzle session for a specific user."""
|
||||||
|
|
||||||
__tablename__ = "game_state"
|
__tablename__ = "game_state"
|
||||||
|
|
||||||
|
# These identifiers connect a save point to one concrete database row and
|
||||||
|
# to the user who owns it, which is necessary so save points stay private.
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
user_id = db.Column(db.Integer, nullable=False)
|
user_id = db.Column(db.Integer, nullable=False)
|
||||||
|
|
||||||
|
# The remaining fields describe the board snapshot and the surrounding
|
||||||
|
# progress information, because a meaningful restore needs more than just
|
||||||
|
# piece coordinates.
|
||||||
board = db.Column(db.Text, nullable=False)
|
board = db.Column(db.Text, nullable=False)
|
||||||
mode = db.Column(db.String(10), nullable=False)
|
mode = db.Column(db.String(10), nullable=False)
|
||||||
board_size = db.Column(db.Integer, nullable=False, default=8)
|
board_size = db.Column(db.Integer, nullable=False, default=8)
|
||||||
@@ -16,6 +25,9 @@ class GameState(db.Model):
|
|||||||
step_count = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
step_count = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
||||||
elapsed_seconds = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
elapsed_seconds = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
||||||
is_solved = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
|
is_solved = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
|
||||||
|
|
||||||
|
# Timestamps make it possible to sort, filter, and explain save points in
|
||||||
|
# the UI without asking the user to remember when each run was created.
|
||||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
||||||
updated_at = db.Column(
|
updated_at = db.Column(
|
||||||
db.DateTime,
|
db.DateTime,
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
|
"""Database model for temporary login-throttling information."""
|
||||||
|
|
||||||
from .. import db
|
from .. import db
|
||||||
|
|
||||||
|
|
||||||
class LoginAttempt(db.Model):
|
class LoginAttempt(db.Model):
|
||||||
|
"""Track failed login attempts per IP address."""
|
||||||
|
|
||||||
__tablename__ = "login_attempts"
|
__tablename__ = "login_attempts"
|
||||||
|
|
||||||
|
# The unique IP address is the central lookup key because the lockout
|
||||||
|
# policy is applied to the origin of failed requests, not to one account.
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
ip_address = db.Column(db.String(45), unique=True, nullable=False)
|
ip_address = db.Column(db.String(45), unique=True, nullable=False)
|
||||||
|
|
||||||
|
# These values explain how far an address is into the lock flow and when
|
||||||
|
# the application may safely allow another login attempt.
|
||||||
failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
||||||
locked_until = db.Column(db.DateTime)
|
locked_until = db.Column(db.DateTime)
|
||||||
last_failed_at = db.Column(db.DateTime)
|
last_failed_at = db.Column(db.DateTime)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
"""Return a compact identifier for debugging and logging.
|
||||||
|
"""
|
||||||
|
|
||||||
return f"<LoginAttempt {self.ip_address}>"
|
return f"<LoginAttempt {self.ip_address}>"
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
|
"""Database model for application user accounts."""
|
||||||
|
|
||||||
from .. import db
|
from .. import db
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
|
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
|
"""Represent one authenticated account in the application."""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
# These core identity fields are the minimum needed to create and safely
|
||||||
|
# authenticate an account.
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(20), unique=True, nullable=False)
|
username = db.Column(db.String(20), unique=True, nullable=False)
|
||||||
password = db.Column(db.String(255), nullable=False)
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
# The metadata and 2FA fields support account history and stronger account
|
||||||
|
# protection without changing the basic login flow.
|
||||||
created_at = db.Column(db.DateTime, server_default=db.func.now())
|
created_at = db.Column(db.DateTime, server_default=db.func.now())
|
||||||
last_login = db.Column(db.DateTime)
|
last_login = db.Column(db.DateTime)
|
||||||
two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
|
two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
|
||||||
two_factor_secret = db.Column(db.String(64))
|
two_factor_secret = db.Column(db.String(64))
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
"""Return a readable representation for debugging output.
|
||||||
|
"""
|
||||||
|
|
||||||
return f"<User {self.username}>"
|
return f"<User {self.username}>"
|
||||||
|
|||||||
+79
-69
@@ -1,10 +1,4 @@
|
|||||||
"""Authentication routes and login-related helper functions.
|
"""Authentication routes and security helpers for account access."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
@@ -24,10 +18,11 @@ from .. import db
|
|||||||
from ..models.login_attempt import LoginAttempt
|
from ..models.login_attempt import LoginAttempt
|
||||||
from ..models.user import User
|
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__)
|
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
|
MIN_PASSWORD_LENGTH = 8
|
||||||
MAX_PASSWORD_LENGTH = 128
|
MAX_PASSWORD_LENGTH = 128
|
||||||
MAX_LOGIN_FAILURES = 4
|
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")
|
DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protection")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# General helper functions
|
||||||
|
|
||||||
def utcnow():
|
def utcnow():
|
||||||
"""Return the current UTC time as datetime."""
|
"""Return the current UTC time for security-related comparisons."""
|
||||||
return datetime.utcnow()
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
def get_utc_timestamp(moment=None):
|
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:
|
if moment is None:
|
||||||
return int(datetime.now(timezone.utc).timestamp())
|
return int(datetime.now(timezone.utc).timestamp())
|
||||||
|
|
||||||
@@ -58,19 +56,50 @@ def get_utc_timestamp(moment=None):
|
|||||||
|
|
||||||
|
|
||||||
def normalize_username(username):
|
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()
|
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):
|
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):
|
if not USERNAME_PATTERN.fullmatch(username):
|
||||||
return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores."
|
return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores."
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def validate_password(password):
|
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:
|
if len(password) < MIN_PASSWORD_LENGTH:
|
||||||
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long."
|
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long."
|
||||||
|
|
||||||
@@ -80,8 +109,11 @@ def validate_password(password):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 2. Anti brute force script
|
||||||
|
|
||||||
def get_request_ip():
|
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", "")
|
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||||
|
|
||||||
if forwarded_for:
|
if forwarded_for:
|
||||||
@@ -91,7 +123,7 @@ def get_request_ip():
|
|||||||
|
|
||||||
|
|
||||||
def get_or_create_login_attempt():
|
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()
|
ip_address = get_request_ip()
|
||||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address).first()
|
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):
|
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:
|
if attempt.locked_until and attempt.locked_until <= now:
|
||||||
attempt.failed_attempts = 0
|
attempt.failed_attempts = 0
|
||||||
attempt.locked_until = None
|
attempt.locked_until = None
|
||||||
|
|
||||||
|
|
||||||
def get_active_lock():
|
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()
|
now = utcnow()
|
||||||
attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first()
|
attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first()
|
||||||
|
|
||||||
@@ -118,13 +150,12 @@ def get_active_lock():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
clear_expired_lock(attempt, now)
|
clear_expired_lock(attempt, now)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return attempt.locked_until
|
return attempt.locked_until
|
||||||
|
|
||||||
|
|
||||||
def record_failed_login():
|
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()
|
now = utcnow()
|
||||||
attempt = get_or_create_login_attempt()
|
attempt = get_or_create_login_attempt()
|
||||||
clear_expired_lock(attempt, now)
|
clear_expired_lock(attempt, now)
|
||||||
@@ -139,7 +170,7 @@ def record_failed_login():
|
|||||||
|
|
||||||
|
|
||||||
def clear_login_attempts():
|
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()
|
attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first()
|
||||||
|
|
||||||
if attempt:
|
if attempt:
|
||||||
@@ -148,44 +179,33 @@ def clear_login_attempts():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
def lookup_user_by_username(username):
|
# -----------------------------------------------------------------------------
|
||||||
"""Look up a user case-insensitively so login is not case-sensitive."""
|
# 3. 2-factor authentication
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_two_factor_secret(secret):
|
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()
|
return (secret or "").strip().replace(" ", "").upper()
|
||||||
|
|
||||||
|
|
||||||
def generate_two_factor_secret():
|
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("=")
|
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
def format_two_factor_secret(secret):
|
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)
|
normalized = normalize_two_factor_secret(secret)
|
||||||
return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4))
|
return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4))
|
||||||
|
|
||||||
|
|
||||||
def clear_pending_two_factor(user=None):
|
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_user_id", None)
|
||||||
session.pop("pending_2fa_attempts", None)
|
session.pop("pending_2fa_attempts", None)
|
||||||
|
|
||||||
|
|
||||||
def get_pending_two_factor_setup_secret(user):
|
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:
|
if session.get("pending_2fa_setup_user_id") != user.id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -194,13 +214,13 @@ def get_pending_two_factor_setup_secret(user):
|
|||||||
|
|
||||||
|
|
||||||
def clear_pending_two_factor_setup():
|
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_user_id", None)
|
||||||
session.pop("pending_2fa_setup_secret", None)
|
session.pop("pending_2fa_setup_secret", None)
|
||||||
|
|
||||||
|
|
||||||
def build_totp_uri(user, secret):
|
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)
|
normalized_secret = normalize_two_factor_secret(secret)
|
||||||
label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}")
|
label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}")
|
||||||
issuer = quote(TWO_FACTOR_ISSUER)
|
issuer = quote(TWO_FACTOR_ISSUER)
|
||||||
@@ -212,14 +232,14 @@ def build_totp_uri(user, secret):
|
|||||||
|
|
||||||
|
|
||||||
def decode_two_factor_secret(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)
|
normalized_secret = normalize_two_factor_secret(secret)
|
||||||
padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8))
|
padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8))
|
||||||
return base64.b32decode(padded_secret, casefold=True)
|
return base64.b32decode(padded_secret, casefold=True)
|
||||||
|
|
||||||
|
|
||||||
def generate_totp_code(secret, for_time=None):
|
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)
|
timestamp = get_utc_timestamp(for_time)
|
||||||
counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS
|
counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS
|
||||||
key = decode_two_factor_secret(secret)
|
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):
|
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()
|
normalized_code = (code or "").strip()
|
||||||
|
|
||||||
if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS:
|
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():
|
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")
|
user_id = session.get("pending_2fa_user_id")
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -262,23 +282,12 @@ def get_pending_two_factor_user():
|
|||||||
return db.session.get(User, user_id)
|
return db.session.get(User, user_id)
|
||||||
|
|
||||||
|
|
||||||
def render_login_settings(error=None):
|
# -----------------------------------------------------------------------------
|
||||||
"""Render the login settings page with optional 2FA setup data."""
|
# Routes
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_bp.route("/register", methods=["GET", "POST"])
|
@login_bp.route("/register", methods=["GET", "POST"])
|
||||||
def register():
|
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:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
@@ -298,6 +307,7 @@ def register():
|
|||||||
if lookup_user_by_username(username):
|
if lookup_user_by_username(username):
|
||||||
return render_template("sign_up.html", error="Username already taken!", 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))
|
new_user = User(username=username, password=generate_password_hash(password))
|
||||||
db.session.add(new_user)
|
db.session.add(new_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -310,7 +320,7 @@ def register():
|
|||||||
|
|
||||||
@login_bp.route("/login", methods=["GET", "POST"])
|
@login_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
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:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
@@ -321,7 +331,7 @@ def login():
|
|||||||
if not username or not password:
|
if not username or not password:
|
||||||
return render_template("login.html", error="Username and password are required.", username=username)
|
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()
|
locked_until = get_active_lock()
|
||||||
|
|
||||||
if locked_until and locked_until > utcnow():
|
if locked_until and locked_until > utcnow():
|
||||||
@@ -332,8 +342,8 @@ def login():
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = lookup_user_by_username(username)
|
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_hash = user.password if user else DUMMY_PASSWORD_HASH
|
||||||
password_matches = check_password_hash(password_hash, password)
|
password_matches = check_password_hash(password_hash, password)
|
||||||
|
|
||||||
@@ -351,7 +361,7 @@ def login():
|
|||||||
clear_login_attempts()
|
clear_login_attempts()
|
||||||
|
|
||||||
if user.two_factor_enabled:
|
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:
|
if not user.two_factor_secret:
|
||||||
return render_template(
|
return render_template(
|
||||||
"login.html",
|
"login.html",
|
||||||
@@ -376,7 +386,7 @@ def login():
|
|||||||
|
|
||||||
@login_bp.route("/login/verify", methods=["GET", "POST"])
|
@login_bp.route("/login/verify", methods=["GET", "POST"])
|
||||||
def verify_two_factor():
|
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:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
@@ -397,7 +407,7 @@ def verify_two_factor():
|
|||||||
|
|
||||||
attempts = int(session.get("pending_2fa_attempts", 0))
|
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:
|
if attempts >= MAX_TWO_FACTOR_ATTEMPTS:
|
||||||
clear_pending_two_factor(user)
|
clear_pending_two_factor(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -447,8 +457,7 @@ def login_settings():
|
|||||||
if current_user.two_factor_enabled:
|
if current_user.two_factor_enabled:
|
||||||
return render_login_settings(error="Authenticator app 2FA is already enabled.")
|
return render_login_settings(error="Authenticator app 2FA is already enabled.")
|
||||||
|
|
||||||
# Store the generated secret in the session until the user confirms
|
# The secret stays temporary until the user proves the app was configured correctly.
|
||||||
# it with a valid authenticator code.
|
|
||||||
secret = generate_two_factor_secret()
|
secret = generate_two_factor_secret()
|
||||||
session["pending_2fa_setup_user_id"] = current_user.id
|
session["pending_2fa_setup_user_id"] = current_user.id
|
||||||
session["pending_2fa_setup_secret"] = secret
|
session["pending_2fa_setup_secret"] = secret
|
||||||
@@ -491,6 +500,7 @@ def login_settings():
|
|||||||
new_password = request.form.get("new_password") or ""
|
new_password = request.form.get("new_password") or ""
|
||||||
confirm_password = request.form.get("confirm_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):
|
if not check_password_hash(current_user.password, current_password):
|
||||||
return render_login_settings(error="Your current password is incorrect.")
|
return render_login_settings(error="Your current password is incorrect.")
|
||||||
|
|
||||||
@@ -515,7 +525,7 @@ def login_settings():
|
|||||||
@login_bp.route("/logout", methods=["POST"])
|
@login_bp.route("/logout", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def logout():
|
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()
|
||||||
clear_pending_two_factor_setup()
|
clear_pending_two_factor_setup()
|
||||||
logout_user()
|
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."""
|
"""Convert the legacy one-piece-per-row format into a board matrix."""
|
||||||
board = create_empty_board(board_size)
|
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):
|
for row, col in enumerate(piece_positions):
|
||||||
if isinstance(col, int) and 0 <= col < board_size:
|
if isinstance(col, int) and 0 <= col < board_size:
|
||||||
board[row][col] = 1
|
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):
|
if all(isinstance(row, list) and len(row) == board_size for row in candidate_board):
|
||||||
normalized_board = create_empty_board(board_size)
|
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 row_index, row in enumerate(candidate_board):
|
||||||
for col_index, value in enumerate(row):
|
for col_index, value in enumerate(row):
|
||||||
normalized_board[row_index][col_index] = 1 if value else 0
|
normalized_board[row_index][col_index] = 1 if value else 0
|
||||||
@@ -120,6 +124,7 @@ def solve_queens(board_size):
|
|||||||
def place_queen(row):
|
def place_queen(row):
|
||||||
"""Backtracking search that fills the board row by row."""
|
"""Backtracking search that fills the board row by row."""
|
||||||
if row == board_size:
|
if row == board_size:
|
||||||
|
# A copy is stored because the same list is reused while backtracking.
|
||||||
queen_solutions.append(queen_positions[:])
|
queen_solutions.append(queen_positions[:])
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -136,6 +141,9 @@ def solve_queens(board_size):
|
|||||||
def solve_rooks(board_size):
|
def solve_rooks(board_size):
|
||||||
"""Return one valid rook arrangement with exactly one rook per row/column."""
|
"""Return one valid rook arrangement with exactly one rook per row/column."""
|
||||||
rook_positions = list(range(board_size))
|
rook_positions = list(range(board_size))
|
||||||
|
|
||||||
|
# Randomizing the order keeps rook mode from always returning the same
|
||||||
|
# visually predictable arrangement.
|
||||||
random.shuffle(rook_positions)
|
random.shuffle(rook_positions)
|
||||||
return 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."""
|
"""Convert a database model into JSON-friendly data for the frontend."""
|
||||||
board = normalize_board(json.loads(game_state.board), game_state.board_size)
|
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 {
|
return {
|
||||||
"id": game_state.id,
|
"id": game_state.id,
|
||||||
"board": board,
|
"board": board,
|
||||||
@@ -294,6 +304,7 @@ def build_save_point_query():
|
|||||||
"favorites": GameState.is_favorite.desc()
|
"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())
|
return query.order_by(sort_options.get(sort_by, GameState.created_at.desc()), GameState.id.desc())
|
||||||
|
|
||||||
|
|
||||||
@@ -308,6 +319,9 @@ def home():
|
|||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
"""Render the logged-in 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))
|
force_default_dashboard_mode = bool(session.pop("start_dashboard_with_default_mode", False))
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
@@ -342,6 +356,8 @@ def check():
|
|||||||
if current_row == row and current_col == col:
|
if current_row == row and current_col == col:
|
||||||
continue
|
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_row = current_row == row
|
||||||
same_column = current_col == col
|
same_column = current_col == col
|
||||||
same_diagonal = abs(current_col - col) == abs(current_row - row)
|
same_diagonal = abs(current_col - col) == abs(current_row - row)
|
||||||
@@ -474,7 +490,7 @@ def load_game():
|
|||||||
.first()
|
.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:
|
if not latest_game:
|
||||||
return jsonify({"board": create_empty_board(DEFAULT_BOARD_SIZE), "mode": "queens"})
|
return jsonify({"board": create_empty_board(DEFAULT_BOARD_SIZE), "mode": "queens"})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from app import create_app, db
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_instance(tmp_path):
|
||||||
|
db_path = Path(tmp_path) / "test_app.db"
|
||||||
|
app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SECRET_KEY": "test-secret-key",
|
||||||
|
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
yield app
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app_instance):
|
||||||
|
return app_instance.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_factory(app_instance):
|
||||||
|
def _create_user(username="alice", password="Password123!", **extra_fields):
|
||||||
|
with app_instance.app_context():
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password=generate_password_hash(password),
|
||||||
|
**extra_fields,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
return _create_user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def login_as(client):
|
||||||
|
def _login(username="alice", password="Password123!"):
|
||||||
|
return client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": username, "password": password},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _login
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Testdurchführung
|
||||||
|
|
||||||
|
## Durchführungsrahmen
|
||||||
|
|
||||||
|
- Datum: 2026-04-24
|
||||||
|
- Ausführung auf zwei Ebenen:
|
||||||
|
- Unit-Tests
|
||||||
|
- Integrations-/Routentests
|
||||||
|
- Werkzeug: `pytest 8.3.4`
|
||||||
|
- Testkommando:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 /tmp/acht-damen-test-venv/bin/pytest -q test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bewertung der durchgeführten Testfälle
|
||||||
|
|
||||||
|
| ID | Testfall | Ebene | Erwartung | Resultat | Bewertung | Nachvollziehbare Bewertung |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| U-MAIN-01 | Brettgrösse wird begrenzt | Unit | Grenzwerte werden korrekt behandelt | Bestanden | OK | Kleine, grosse und ungültige Werte wurden korrekt auf den erlaubten Bereich abgebildet |
|
||||||
|
| U-MAIN-02 | Favoriten-Flag wird erkannt | Unit | Typische truthy Werte werden korrekt erkannt | Bestanden | OK | Die Hilfsfunktion unterschied erfolgreich zwischen gültigen truthy und anderen Werten |
|
||||||
|
| U-MAIN-03 | Nicht-negative Ganzzahlen werden abgesichert | Unit | Negative und ungültige Werte werden abgefangen | Bestanden | OK | Negative Werte wurden auf 0 begrenzt, ungültige Eingaben fielen auf den Default zurück |
|
||||||
|
| U-MAIN-04 | Legacy-Brett wird umgewandelt | Unit | Positionsliste wird zur Matrix | Bestanden | OK | Altes Speicherformat konnte verlustfrei in das aktülle Format übernommen werden |
|
||||||
|
| U-MAIN-05 | Matrixwerte werden normalisiert | Unit | Nur 0/1 bleiben übrig | Bestanden | OK | Truthy- und falsy-Werte wurden korrekt vereinheitlicht |
|
||||||
|
| U-MAIN-06 | Damen-Solver für 4x4 | Unit | Gültige Standardlösungen werden gefunden | Bestanden | OK | Der Solver lieferte die fachlich erwarteten zwei 4x4-Lösungen |
|
||||||
|
| U-MAIN-07 | Turm-Trace ist vollständig | Unit | Pro Reihe ein Schritt plus Abschluss | Bestanden | OK | Der Trace enthielt die erwartete Struktur für das Frontend |
|
||||||
|
| U-LOGIN-01 | Gültiger Username | Unit | Gültiger Name wird akzeptiert | Bestanden | OK | Ein Name im erlaubten Format wurde ohne Fehlermeldung akzeptiert |
|
||||||
|
| U-LOGIN-02 | Ungültiger Username | Unit | Fehlerhafte Zeichen werden abgelehnt | Bestanden | OK | Ein Name mit Leerzeichen/Sonderzeichen wurde korrekt abgewiesen |
|
||||||
|
| U-LOGIN-03 | Zu kurzes Passwort | Unit | Mindestlänge wird erzwungen | Bestanden | OK | Die Funktion lieferte die passende Fehlermeldung für zu kurze Passwörter |
|
||||||
|
| U-LOGIN-04 | Zu langes Passwort | Unit | Maximallänge wird erzwungen | Bestanden | OK | Auch überlange Passwörter wurden korrekt abgefangen |
|
||||||
|
| U-LOGIN-05 | 2FA-Secret-Normalisierung | Unit | Secret wird korrekt bereinigt und formatiert | Bestanden | OK | Schreibweisen mit Leerzeichen/Kleinschreibung wurden korrekt vereinheitlicht |
|
||||||
|
| U-LOGIN-06 | TOTP zur gleichen Zeit | Unit | Richtiger Code ist gültig | Bestanden | OK | Erzeugter Code wurde bei identischem Zeitpunkt akzeptiert |
|
||||||
|
| U-LOGIN-07 | TOTP mit Zeitfenster | Unit | Code bleibt im gültigen Nachbarfenster gültig | Bestanden | OK | Das erlaubte Zeitfenster funktionierte wie vorgesehen |
|
||||||
|
| U-LOGIN-08 | Fehlerhafte TOTP-Eingabe | Unit | Falsch formatierte Codes werden abgelehnt | Bestanden | OK | Nicht numerische oder zu kurze Eingaben wurden nicht akzeptiert |
|
||||||
|
| I-APP-01 | Dashboard ohne Login | Integration | Weiterleitung auf Login | Bestanden | OK | Die geschützte Route war ohne Authentifizierung nicht direkt erreichbar |
|
||||||
|
| I-APP-02 | Registrierung | Integration | Benutzer wird erstellt und umgeleitet | Bestanden | OK | Nach dem POST war der Benutzer in der Testdatenbank vorhanden |
|
||||||
|
| I-APP-03 | Login bei gesperrter IP | Integration | Sperrmeldung statt Login | Bestanden | OK | Die Anti-Brute-Force-Logik blockierte den Login im gesperrten Zeitraum korrekt |
|
||||||
|
| I-APP-04 | Dame vs. Turm bei Diagonale | Integration | Dame ungültig, Turm gültig | Bestanden | OK | Die Route `/check` setzte die unterschiedlichen Fachregeln korrekt um |
|
||||||
|
| I-APP-05 | Speichern und Laden | Integration | Spielstand bleibt konsistent | Bestanden | OK | Gespeicherter Spielstand konnte mit denselben Daten wieder geladen werden |
|
||||||
|
| I-APP-06 | Favoriten filtern | Integration | Nur markierte Save-Points erscheinen im Filter | Bestanden | OK | Das Umschalten des Favoritenstatus wirkte sich korrekt auf die Filterliste aus |
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
- Anzahl Testebenen: 2
|
||||||
|
- Anzahl dokumentierter Testfälle: 21
|
||||||
|
- Bestandene Testfälle: 21
|
||||||
|
- Nicht bestandene Testfälle: 0
|
||||||
|
|
||||||
|
Die Testdurchführung zeigt, dass die zentrale Fachlogik, die
|
||||||
|
Authentifizierungslogik und die wichtigsten Datenflüsse der Anwendung für die
|
||||||
|
Abgabe nachvollziehbar und erfolgreich überprüft wurden.
|
||||||
|
|
||||||
|
## Hinweis zu Warnungen
|
||||||
|
|
||||||
|
Beim Testlauf traten nur Warnungen auf, aber keine Fehler:
|
||||||
|
|
||||||
|
- DeprecationWarnings zu `datetime.utcnow()` in bestehendem Anwendungscode
|
||||||
|
- Pytest-Cache-Warnungen wegen schreibgeschütztem Dateisystem im Projektpfad
|
||||||
|
|
||||||
|
Diese Warnungen haben das Ergebnis der Testfälle nicht beeinflusst. Alle
|
||||||
|
geplanten und ausgeführten Tests wurden bestanden.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models.gamestate import GameState
|
||||||
|
from app.models.login_attempt import LoginAttempt
|
||||||
|
from app.models.user import User
|
||||||
|
from app.routes.login_routes import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_requires_login(client):
|
||||||
|
response = client.get("/dashboard")
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/login" in response.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_creates_user_and_redirects_to_login(client, app_instance):
|
||||||
|
response = client.post(
|
||||||
|
"/register",
|
||||||
|
data={"username": "newuser", "password": "Password123!"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/login" in response.headers["Location"]
|
||||||
|
|
||||||
|
with app_instance.app_context():
|
||||||
|
saved_user = User.query.filter_by(username="newuser").first()
|
||||||
|
assert saved_user is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_blocks_requests_from_a_locked_ip(client, app_instance, user_factory):
|
||||||
|
user_factory(username="lockeduser", password="Password123!")
|
||||||
|
|
||||||
|
with app_instance.app_context():
|
||||||
|
db.session.add(
|
||||||
|
LoginAttempt(
|
||||||
|
ip_address="203.0.113.10",
|
||||||
|
failed_attempts=4,
|
||||||
|
locked_until=utcnow() + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "lockeduser", "password": "Password123!"},
|
||||||
|
headers={"X-Forwarded-For": "203.0.113.10"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Too many failed login attempts" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_endpoint_applies_different_rules_for_queens_and_rooks(client):
|
||||||
|
board = [
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
]
|
||||||
|
|
||||||
|
queens_response = client.post(
|
||||||
|
"/check",
|
||||||
|
json={"board": board, "row": 1, "col": 1, "mode": "queens"},
|
||||||
|
)
|
||||||
|
rooks_response = client.post(
|
||||||
|
"/check",
|
||||||
|
json={"board": board, "row": 1, "col": 1, "mode": "rooks"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert queens_response.status_code == 200
|
||||||
|
assert rooks_response.status_code == 200
|
||||||
|
assert queens_response.get_json() == {"valid": False}
|
||||||
|
assert rooks_response.get_json() == {"valid": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_user_can_save_and_load_a_game_state(client, app_instance, user_factory, login_as):
|
||||||
|
user_factory(username="alice", password="Password123!")
|
||||||
|
|
||||||
|
login_response = login_as("alice", "Password123!")
|
||||||
|
assert login_response.status_code == 302
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"board": [
|
||||||
|
[0, 1, 0, 0],
|
||||||
|
[0, 0, 0, 1],
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
],
|
||||||
|
"mode": "queens",
|
||||||
|
"boardSize": 4,
|
||||||
|
"saveName": "test-save",
|
||||||
|
"saveNote": "unit integration save",
|
||||||
|
"isFavorite": False,
|
||||||
|
"stepCount": 7,
|
||||||
|
"elapsedSeconds": 42,
|
||||||
|
"isSolved": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_response = client.post("/save", json=payload)
|
||||||
|
assert save_response.status_code == 200
|
||||||
|
|
||||||
|
saved_data = save_response.get_json()["save_point"]
|
||||||
|
load_response = client.get(f"/load/{saved_data['id']}")
|
||||||
|
|
||||||
|
assert load_response.status_code == 200
|
||||||
|
loaded_data = load_response.get_json()
|
||||||
|
assert loaded_data["board"] == payload["board"]
|
||||||
|
assert loaded_data["mode"] == payload["mode"]
|
||||||
|
assert loaded_data["step_count"] == payload["stepCount"]
|
||||||
|
assert loaded_data["elapsed_seconds"] == payload["elapsedSeconds"]
|
||||||
|
assert loaded_data["is_solved"] is True
|
||||||
|
|
||||||
|
with app_instance.app_context():
|
||||||
|
assert GameState.query.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_favorite_toggle_changes_filtered_save_point_list(client, user_factory, login_as):
|
||||||
|
user_factory(username="favuser", password="Password123!")
|
||||||
|
|
||||||
|
login_response = login_as("favuser", "Password123!")
|
||||||
|
assert login_response.status_code == 302
|
||||||
|
|
||||||
|
save_response = client.post(
|
||||||
|
"/save",
|
||||||
|
json={
|
||||||
|
"board": [
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 1, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
],
|
||||||
|
"mode": "rooks",
|
||||||
|
"boardSize": 4,
|
||||||
|
"saveName": "favorite-candidate",
|
||||||
|
"saveNote": "",
|
||||||
|
"isFavorite": False,
|
||||||
|
"stepCount": 2,
|
||||||
|
"elapsedSeconds": 5,
|
||||||
|
"isSolved": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
save_id = save_response.get_json()["save_point"]["id"]
|
||||||
|
|
||||||
|
filtered_before = client.get("/save_points?favorites_only=true")
|
||||||
|
assert filtered_before.status_code == 200
|
||||||
|
assert filtered_before.get_json() == []
|
||||||
|
|
||||||
|
toggle_response = client.post(f"/save_points/{save_id}/favorite")
|
||||||
|
assert toggle_response.status_code == 200
|
||||||
|
assert toggle_response.get_json()["is_favorite"] is True
|
||||||
|
|
||||||
|
filtered_after = client.get("/save_points?favorites_only=true")
|
||||||
|
assert filtered_after.status_code == 200
|
||||||
|
assert len(filtered_after.get_json()) == 1
|
||||||
|
assert filtered_after.get_json()[0]["id"] == save_id
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.routes.login_routes import (
|
||||||
|
format_two_factor_secret,
|
||||||
|
generate_totp_code,
|
||||||
|
normalize_two_factor_secret,
|
||||||
|
validate_password,
|
||||||
|
validate_username,
|
||||||
|
verify_totp_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_username_accepts_supported_format():
|
||||||
|
assert validate_username("valid_user-01") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_username_rejects_unsupported_characters():
|
||||||
|
assert validate_username("bad user!") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_password_rejects_too_short_passwords():
|
||||||
|
assert validate_password("short") == "Password must be at least 8 characters long."
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_password_rejects_too_long_passwords():
|
||||||
|
assert validate_password("a" * 129) == "Password must be at most 128 characters long."
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_and_format_two_factor_secret_prepare_manual_input():
|
||||||
|
secret = "jbsw y3dp ehpk 3pxp"
|
||||||
|
|
||||||
|
assert normalize_two_factor_secret(secret) == "JBSWY3DPEHPK3PXP"
|
||||||
|
assert format_two_factor_secret(secret) == "JBSW Y3DP EHPK 3PXP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_totp_code_accepts_code_generated_for_same_time():
|
||||||
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
moment = datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
code = generate_totp_code(secret, moment)
|
||||||
|
|
||||||
|
assert verify_totp_code(secret, code, moment) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_totp_code_accepts_code_in_adjacent_time_window():
|
||||||
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
moment = datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
code = generate_totp_code(secret, moment)
|
||||||
|
|
||||||
|
assert verify_totp_code(secret, code, moment + timedelta(seconds=30)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_totp_code_rejects_malformed_code_input():
|
||||||
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
moment = datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
assert verify_totp_code(secret, "12ab56", moment) is False
|
||||||
|
assert verify_totp_code(secret, "12345", moment) is False
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from app.routes.main_routes import (
|
||||||
|
build_rooks_trace,
|
||||||
|
count_placed_pieces,
|
||||||
|
normalize_board,
|
||||||
|
parse_board_size,
|
||||||
|
parse_favorite_flag,
|
||||||
|
parse_non_negative_int,
|
||||||
|
solve_queens,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_board_size_clamps_small_large_and_invalid_values():
|
||||||
|
assert parse_board_size("2") == 4
|
||||||
|
assert parse_board_size("99") == 13
|
||||||
|
assert parse_board_size("abc") == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_favorite_flag_accepts_common_truthy_values():
|
||||||
|
assert parse_favorite_flag("true") is True
|
||||||
|
assert parse_favorite_flag("1") is True
|
||||||
|
assert parse_favorite_flag("on") is True
|
||||||
|
assert parse_favorite_flag("false") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_non_negative_int_clamps_negative_and_invalid_values():
|
||||||
|
assert parse_non_negative_int("-7") == 0
|
||||||
|
assert parse_non_negative_int("15") == 15
|
||||||
|
assert parse_non_negative_int("abc", default=3) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_board_converts_legacy_position_list_into_matrix():
|
||||||
|
normalized = normalize_board([1, 3, 0, 2], 4)
|
||||||
|
|
||||||
|
assert normalized == [
|
||||||
|
[0, 1, 0, 0],
|
||||||
|
[0, 0, 0, 1],
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_board_reduces_matrix_values_to_zero_or_one():
|
||||||
|
normalized = normalize_board([[0, 2], [True, None]], 2)
|
||||||
|
|
||||||
|
assert normalized == [[0, 1], [1, 0]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_solve_queens_returns_the_two_expected_solutions_for_4x4():
|
||||||
|
solutions = {tuple(solution) for solution in solve_queens(4)}
|
||||||
|
|
||||||
|
assert solutions == {
|
||||||
|
(1, 3, 0, 2),
|
||||||
|
(2, 0, 3, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_rooks_trace_contains_one_placement_per_row_plus_solution():
|
||||||
|
trace = build_rooks_trace(5)
|
||||||
|
placement_steps = [step for step in trace["steps"] if step["type"] == "place"]
|
||||||
|
|
||||||
|
assert trace["solved"] is True
|
||||||
|
assert len(trace["steps"]) == 6
|
||||||
|
assert len(placement_steps) == 5
|
||||||
|
assert sorted(step["col"] for step in placement_steps) == [0, 1, 2, 3, 4]
|
||||||
|
assert count_placed_pieces(trace["steps"][-1]["board"]) == 5
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Testkonzept
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Dieses Testkonzept beschreibt, wie die wichtigsten Funktionen der Webanwendung
|
||||||
|
vor der Abgabe ueberprueft werden. Der Fokus liegt auf den fachlich und
|
||||||
|
technisch kritischen Bereichen:
|
||||||
|
|
||||||
|
- Brettlogik fuer Damen und Tuerme
|
||||||
|
- Authentifizierung und Sicherheitsfunktionen
|
||||||
|
- Speichern, Laden und Filtern von Spielständen
|
||||||
|
|
||||||
|
## Randbedingungen / Umfeld
|
||||||
|
|
||||||
|
- Projekt: 8-Damen- und 8-Tuerme Solver
|
||||||
|
- Testdatum Planung: 2026-04-24
|
||||||
|
- Betriebssystem: Linux 6.6.87.2-microsoft-standard-WSL2 x86_64
|
||||||
|
- Python-Version: 3.12.3
|
||||||
|
- Testwerkzeug: pytest 8.3.4
|
||||||
|
- Webtestmittel: Flask Test Client
|
||||||
|
- Datenbank im Test: isolierte SQLite-Testdatenbank pro Testlauf
|
||||||
|
- Testordner: `test/`
|
||||||
|
|
||||||
|
Die Tests werden absichtlich getrennt von der produktiven Datenbank
|
||||||
|
durchgefuehrt. Dadurch wird verhindert, dass echte Benutzerdaten oder
|
||||||
|
Spielstände verändert werden.
|
||||||
|
|
||||||
|
## Testmittel und Methoden
|
||||||
|
|
||||||
|
Es werden zwei voneinander unabhängige Testebenen eingesetzt:
|
||||||
|
|
||||||
|
1. Unit-Tests
|
||||||
|
Diese prüfen einzelne Funktionen direkt, zum Beispiel Eingabevalidierung,
|
||||||
|
Brettnormalisierung oder TOTP-Prüfung.
|
||||||
|
2. Integrations- und Routentests
|
||||||
|
Diese prüfen das Zusammenspiel mehrerer Komponenten über HTTP-Routen, zum
|
||||||
|
Beispiel Registrierung, Login, Validierung, Speichern und Laden.
|
||||||
|
|
||||||
|
Eingesetzte Mittel:
|
||||||
|
|
||||||
|
- `pytest` für die Testausführung und Bewertung
|
||||||
|
- Flask Test Client für Request/Response-Tests
|
||||||
|
- temporäre SQLite-Datenbank für isolierte Testdaten
|
||||||
|
|
||||||
|
## Testfälle (Drehbuch)
|
||||||
|
|
||||||
|
| ID | Ebene | Testziel | Testmethode / Eingabe | Erwartetes Resultat |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| U-MAIN-01 | Unit | Brettgroesse absichern | `parse_board_size()` mit zu kleinen, zu grossen und ungültigen Werten | Werte werden korrekt auf den erlaubten Bereich begrenzt |
|
||||||
|
| U-MAIN-02 | Unit | Favoriten-Flag normalisieren | `parse_favorite_flag()` mit typischen truthy/falsy Werten | Nur definierte truthy Werte werden als `True` interpretiert |
|
||||||
|
| U-MAIN-03 | Unit | Ganze Zahlen robust verarbeiten | `parse_non_negative_int()` mit negativen und ungültigen Werten | Negative Werte werden auf 0 begrenzt, ungültige Werte fallen auf den Default zurück |
|
||||||
|
| U-MAIN-04 | Unit | Alte Speicherformate unterstützen | `normalize_board()` mit Legacy-Positionsliste | Es entsteht ein korrektes Brettmatrix-Format |
|
||||||
|
| U-MAIN-05 | Unit | Uneinheitliche Matrixwerte normalisieren | `normalize_board()` mit truthy/falsy Werten | Es entstehen nur 0- und 1-Werte |
|
||||||
|
| U-MAIN-06 | Unit | Damen-Solver fachlich absichern | `solve_queens(4)` | Es werden genau die gültigen 4x4-Loesungen gefunden |
|
||||||
|
| U-MAIN-07 | Unit | Turm-Trace prüfen | `build_rooks_trace(5)` | Es gibt pro Reihe genau einen Platzierungsschritt plus Abschluss |
|
||||||
|
| U-LOGIN-01 | Unit | Gültigen Username akzeptieren | `validate_username()` mit gültigem Namen | Rückgabe ist `None` |
|
||||||
|
| U-LOGIN-02 | Unit | Ungültigen Username ablehnen | `validate_username()` mit unerlaubten Zeichen | Es wird eine Fehlermeldung geliefert |
|
||||||
|
| U-LOGIN-03 | Unit | Zu kurzes Passwort ablehnen | `validate_password()` mit zu kurzem Passwort | Es wird die Mindestlängen-Fehlermeldung geliefert |
|
||||||
|
| U-LOGIN-04 | Unit | Zu langes Passwort ablehnen | `validate_password()` mit zu langem Passwort | Es wird die Maximalfehler-Meldung geliefert |
|
||||||
|
| U-LOGIN-05 | Unit | 2FA-Secret vereinheitlichen | `normalize_two_factor_secret()` und `format_two_factor_secret()` | Secret wird korrekt bereinigt und formatiert |
|
||||||
|
| U-LOGIN-06 | Unit | TOTP im Normalfall prüfen | `generate_totp_code()` + `verify_totp_code()` zur gleichen Zeit | Gültiger Code wird akzeptiert |
|
||||||
|
| U-LOGIN-07 | Unit | TOTP mit Zeitfenster prüfen | Code wird mit 30 Sekunden Abweichung geprüft | Gültiger Code wird wegen Validierungsfenster akzeptiert |
|
||||||
|
| U-LOGIN-08 | Unit | Fehlerhafte TOTP-Eingabe ablehnen | `verify_totp_code()` mit falschem Format | Ungültige Codes werden abgelehnt |
|
||||||
|
| I-APP-01 | Integration | Schutz geschützter Seite prüfen | `GET /dashboard` ohne Login | Weiterleitung auf Login |
|
||||||
|
| I-APP-02 | Integration | Registrierung prüfen | `POST /register` mit gültigen Daten | Benutzer wird erstellt und Weiterleitung auf Login erfolgt |
|
||||||
|
| I-APP-03 | Integration | Anti-Brute-Force prüfen | `POST /login` mit gesperrter IP | Login wird mit Sperrmeldung abgelehnt |
|
||||||
|
| I-APP-04 | Integration | Fachregel für Brett prüfen | `POST /check` für Dame und Turm auf diagonalem Feld | Dame: ungültig, Turm: gültig |
|
||||||
|
| I-APP-05 | Integration | Speichern und Laden prüfen | Login, danach `POST /save` und `GET /load/<id>` | Spielstand wird korrekt gespeichert und identisch geladen |
|
||||||
|
| I-APP-06 | Integration | Favoritenlogik prüfen | Login, Save-Point speichern, Favorit toggeln, gefiltert abfragen | Filter liefert nur entsprechend markierte Save-Points |
|
||||||
|
|
||||||
|
## Erwartete Ergebnisse
|
||||||
|
|
||||||
|
- Alle Unit-Tests müssen erfolgreich durchlaufen.
|
||||||
|
- Alle Integrations-Tests müssen erfolgreich durchlaufen.
|
||||||
|
- Keine produktiven Daten dürfen verändert werden.
|
||||||
|
- Fehler müssen in `pytest` klar sichtbar sein.
|
||||||
|
- Die Suite umfasst 21 konkrete Pytest-Testfälle auf zwei Ebenen.
|
||||||
|
|
||||||
|
## Durchführung
|
||||||
|
|
||||||
|
Die geplanten Tests werden mit `pytest` aus dem Projektwurzelverzeichnis
|
||||||
|
ausgeführt. Die konkrete Bewertung und das Resultat werden in
|
||||||
|
`test/testkonzept.md` festgehalten.
|
||||||
Reference in New Issue
Block a user