diff --git a/app/__init__.py b/app/__init__.py index ddb86b3..666f398 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,28 +2,52 @@ from flask import Flask import os from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager +from sqlalchemy import inspect, text db = SQLAlchemy() login_manager = LoginManager() + +def apply_startup_migrations(): + inspector = inspect(db.engine) + + if "users" in inspector.get_table_names(): + existing_columns = {column["name"] for column in inspector.get_columns("users")} + user_column_migrations = { + "two_factor_enabled": "ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT 0", + "two_factor_secret": "ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(64)", + } + + for column_name, statement in user_column_migrations.items(): + if column_name not in existing_columns: + db.session.execute(text(statement)) + + db.session.commit() + def create_app(): app = Flask(__name__) BASE_DIR = os.path.abspath(os.path.dirname(__file__)) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db") - app.config["SECRET_KEY"] = "dev-secret-key" + app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["REMEMBER_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" db.init_app(app) login_manager.init_app(app) login_manager.login_view = "login.login" + login_manager.login_message_category = "info" from .models.user import User from .models.gamestate import GameState + from .models.login_attempt import LoginAttempt with app.app_context(): db.create_all() + apply_startup_migrations() @login_manager.user_loader def load_user(user_id): diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc index c75a2cb..b44b30f 100644 Binary files a/app/__pycache__/__init__.cpython-311.pyc and b/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc index ae97128..a633a39 100644 Binary files a/app/__pycache__/__init__.cpython-312.pyc and b/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/models/__pycache__/login_attempt.cpython-311.pyc b/app/models/__pycache__/login_attempt.cpython-311.pyc new file mode 100644 index 0000000..9aa4c7a Binary files /dev/null and b/app/models/__pycache__/login_attempt.cpython-311.pyc differ diff --git a/app/models/__pycache__/login_attempt.cpython-312.pyc b/app/models/__pycache__/login_attempt.cpython-312.pyc new file mode 100644 index 0000000..65fbb7e Binary files /dev/null and b/app/models/__pycache__/login_attempt.cpython-312.pyc differ diff --git a/app/models/__pycache__/user.cpython-311.pyc b/app/models/__pycache__/user.cpython-311.pyc index 0748aa0..bc150d6 100644 Binary files a/app/models/__pycache__/user.cpython-311.pyc and b/app/models/__pycache__/user.cpython-311.pyc differ diff --git a/app/models/__pycache__/user.cpython-312.pyc b/app/models/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..fdc64d3 Binary files /dev/null and b/app/models/__pycache__/user.cpython-312.pyc differ diff --git a/app/models/login_attempt.py b/app/models/login_attempt.py new file mode 100644 index 0000000..cb9baf2 --- /dev/null +++ b/app/models/login_attempt.py @@ -0,0 +1,19 @@ +from .. import db + + +class LoginAttempt(db.Model): + __tablename__ = "login_attempts" + + id = db.Column(db.Integer, primary_key=True) + ip_address = db.Column(db.String(45), nullable=False) + username = db.Column(db.String(80), nullable=False) + failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0")) + locked_until = db.Column(db.DateTime) + last_failed_at = db.Column(db.DateTime) + + __table_args__ = ( + db.UniqueConstraint("ip_address", "username", name="uq_login_attempt_ip_username"), + ) + + def __repr__(self): + return f"" diff --git a/app/models/user.py b/app/models/user.py index 1a70419..ef6e199 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,15 +1,17 @@ from .. import db from flask_login import UserMixin + class User(UserMixin, db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(20), unique=True, nullable=False) - password = db.Column(db.String(255), nullable=False) # <- grâßer! - email = db.Column(db.String(120), unique=True, nullable=True) + password = db.Column(db.String(255), nullable=False) created_at = db.Column(db.DateTime, server_default=db.func.now()) last_login = db.Column(db.DateTime) + two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0")) + two_factor_secret = db.Column(db.String(64)) def __repr__(self): return f"" diff --git a/app/routes/__pycache__/login_routes.cpython-311.pyc b/app/routes/__pycache__/login_routes.cpython-311.pyc index 60142fb..0bc2bad 100644 Binary files a/app/routes/__pycache__/login_routes.cpython-311.pyc and b/app/routes/__pycache__/login_routes.cpython-311.pyc differ diff --git a/app/routes/__pycache__/login_routes.cpython-312.pyc b/app/routes/__pycache__/login_routes.cpython-312.pyc new file mode 100644 index 0000000..698eb89 Binary files /dev/null and b/app/routes/__pycache__/login_routes.cpython-312.pyc differ diff --git a/app/routes/login_routes.py b/app/routes/login_routes.py index 4525a07..9d3148d 100644 --- a/app/routes/login_routes.py +++ b/app/routes/login_routes.py @@ -1,30 +1,291 @@ -from flask import Blueprint, render_template, request, url_for, redirect, flash -from werkzeug.security import generate_password_hash, check_password_hash -from flask_login import login_user, login_required, logout_user -from ..models.user import User +import base64 +import binascii +import hashlib +import hmac +import re +import secrets +import struct +from datetime import datetime, timedelta, timezone +from urllib.parse import quote + +from flask import Blueprint, flash, redirect, render_template, request, session, url_for +from flask_login import current_user, login_required, login_user, logout_user +from werkzeug.security import check_password_hash, generate_password_hash + from .. import db +from ..models.login_attempt import LoginAttempt +from ..models.user import User login_bp = Blueprint("login", __name__) +MIN_PASSWORD_LENGTH = 8 +MAX_PASSWORD_LENGTH = 128 +MAX_LOGIN_FAILURES = 5 +LOGIN_LOCK_MINUTES = 15 +TWO_FACTOR_CODE_DIGITS = 6 +TWO_FACTOR_TIME_STEP_SECONDS = 30 +TWO_FACTOR_VALID_WINDOW = 1 +TWO_FACTOR_ISSUER = "8 Queens Solver" +MAX_TWO_FACTOR_ATTEMPTS = 5 +USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{3,20}$") +DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protection") + + +def utcnow(): + return datetime.utcnow() + + +def get_utc_timestamp(moment=None): + if moment is None: + return int(datetime.now(timezone.utc).timestamp()) + + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + + return int(moment.timestamp()) + + +def normalize_username(username): + return (username or "").strip() + + +def validate_username(username): + 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): + if len(password) < MIN_PASSWORD_LENGTH: + return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long." + + if len(password) > MAX_PASSWORD_LENGTH: + return f"Password must be at most {MAX_PASSWORD_LENGTH} characters long." + + return None + + +def get_request_ip(): + forwarded_for = request.headers.get("X-Forwarded-For", "") + + if forwarded_for: + return forwarded_for.split(",")[0].strip()[:45] or "unknown" + + return (request.remote_addr or "unknown")[:45] + + +def login_attempt_buckets(username): + normalized_username = normalize_username(username).lower() or "__empty__" + ip_address = get_request_ip() + return [ + (ip_address, normalized_username), + (ip_address, "__ip__"), + ] + + +def get_or_create_attempt(ip_address, bucket_key): + attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() + + if attempt is None: + attempt = LoginAttempt(ip_address=ip_address, username=bucket_key) + db.session.add(attempt) + + return attempt + + +def clear_expired_lock(attempt, now): + if attempt.locked_until and attempt.locked_until <= now: + attempt.failed_attempts = 0 + attempt.locked_until = None + + +def get_active_lock(username): + now = utcnow() + active_until = None + + for ip_address, bucket_key in login_attempt_buckets(username): + attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() + + if not attempt: + continue + + clear_expired_lock(attempt, now) + + if attempt.locked_until and (active_until is None or attempt.locked_until > active_until): + active_until = attempt.locked_until + + db.session.commit() + return active_until + + +def record_failed_login(username): + now = utcnow() + locked_until = None + + for ip_address, bucket_key in login_attempt_buckets(username): + attempt = get_or_create_attempt(ip_address, bucket_key) + clear_expired_lock(attempt, now) + attempt.failed_attempts = (attempt.failed_attempts or 0) + 1 + attempt.last_failed_at = now + + if attempt.failed_attempts >= MAX_LOGIN_FAILURES: + attempt.locked_until = now + timedelta(minutes=LOGIN_LOCK_MINUTES) + locked_until = attempt.locked_until + + db.session.commit() + return locked_until + + +def clear_login_attempts(username): + for ip_address, bucket_key in login_attempt_buckets(username): + attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() + + if attempt: + db.session.delete(attempt) + + db.session.commit() + + +def lookup_user_by_username(username): + if not username: + return None + + return User.query.filter(db.func.lower(User.username) == username.lower()).first() + + +def minutes_until(target_time): + seconds = max(1, int((target_time - utcnow()).total_seconds())) + return max(1, (seconds + 59) // 60) + + +def normalize_two_factor_secret(secret): + return (secret or "").strip().replace(" ", "").upper() + + +def generate_two_factor_secret(): + return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=") + + +def format_two_factor_secret(secret): + 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): + session.pop("pending_2fa_user_id", None) + session.pop("pending_2fa_attempts", None) + + +def get_pending_two_factor_setup_secret(user): + if session.get("pending_2fa_setup_user_id") != user.id: + return None + + secret = normalize_two_factor_secret(session.get("pending_2fa_setup_secret")) + return secret or None + + +def clear_pending_two_factor_setup(): + session.pop("pending_2fa_setup_user_id", None) + session.pop("pending_2fa_setup_secret", None) + + +def build_totp_uri(user, secret): + normalized_secret = normalize_two_factor_secret(secret) + label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}") + issuer = quote(TWO_FACTOR_ISSUER) + return ( + f"otpauth://totp/{label}" + f"?secret={normalized_secret}&issuer={issuer}" + f"&algorithm=SHA1&digits={TWO_FACTOR_CODE_DIGITS}&period={TWO_FACTOR_TIME_STEP_SECONDS}" + ) + + +def decode_two_factor_secret(secret): + 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): + timestamp = get_utc_timestamp(for_time) + counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS + key = decode_two_factor_secret(secret) + message = struct.pack(">Q", counter) + digest = hmac.new(key, message, hashlib.sha1).digest() + offset = digest[-1] & 0x0F + truncated_hash = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF + code = truncated_hash % (10 ** TWO_FACTOR_CODE_DIGITS) + return f"{code:0{TWO_FACTOR_CODE_DIGITS}d}" + + +def verify_totp_code(secret, code, at_time=None): + normalized_code = (code or "").strip() + + if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS: + return False + + current_time = at_time or utcnow() + + try: + for window in range(-TWO_FACTOR_VALID_WINDOW, TWO_FACTOR_VALID_WINDOW + 1): + window_time = current_time + timedelta(seconds=window * TWO_FACTOR_TIME_STEP_SECONDS) + + if secrets.compare_digest(generate_totp_code(secret, window_time), normalized_code): + return True + except (TypeError, ValueError, binascii.Error): + return False + + return False + + +def get_pending_two_factor_user(): + user_id = session.get("pending_2fa_user_id") + + if not user_id: + return None + + return db.session.get(User, user_id) + + +def render_login_settings(error=None): + 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"]) def register(): + if current_user.is_authenticated: + return redirect(url_for("main.dashboard")) + if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") + username = normalize_username(request.form.get("username")) + password = request.form.get("password") or "" - if User.query.filter_by(username=username).first(): - return render_template("sign_up.html", error="Username already taken!") + username_error = validate_username(username) + password_error = validate_password(password) - hashed_password = generate_password_hash(password) + if username_error: + return render_template("sign_up.html", error=username_error, username=username) - new_user = User( - username=username, - password=hashed_password - ) + if password_error: + return render_template("sign_up.html", error=password_error, username=username) + if lookup_user_by_username(username): + return render_template("sign_up.html", error="Username already taken!", username=username) + + new_user = User(username=username, password=generate_password_hash(password)) db.session.add(new_user) db.session.commit() + flash("Account created successfully. You can log in now.", "success") return redirect(url_for("login.login")) return render_template("sign_up.html") @@ -32,23 +293,201 @@ def register(): @login_bp.route("/login", methods=["GET", "POST"]) def login(): + if current_user.is_authenticated: + return redirect(url_for("main.dashboard")) + if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") + username = normalize_username(request.form.get("username")) + password = request.form.get("password") or "" - user = User.query.filter_by(username=username).first() + if not username or not password: + return render_template("login.html", error="Username and password are required.", username=username) - if user and check_password_hash(user.password, password): - login_user(user) - return redirect(url_for("main.dashboard")) + locked_until = get_active_lock(username) - return render_template("login.html", error="Invalid username or password") + if locked_until and locked_until > utcnow(): + return render_template( + "login.html", + error=f"Too many failed login attempts. Please try again in about {minutes_until(locked_until)} minute(s).", + username=username, + ) + + user = lookup_user_by_username(username) + password_hash = user.password if user else DUMMY_PASSWORD_HASH + password_matches = check_password_hash(password_hash, password) + + if not user or not password_matches: + locked_until = record_failed_login(username) + error_message = "Invalid username or password." + + if locked_until: + error_message = ( + f"Too many failed login attempts. Please try again in about {minutes_until(locked_until)} minute(s)." + ) + + return render_template("login.html", error=error_message, username=username) + + clear_login_attempts(username) + + if user.two_factor_enabled: + if not user.two_factor_secret: + return render_template( + "login.html", + error="This account requires two-factor authentication, but no authenticator app is configured.", + username=username, + ) + + session["pending_2fa_user_id"] = user.id + session["pending_2fa_attempts"] = 0 + flash("Enter the current 6-digit code from your authenticator app.", "info") + return redirect(url_for("login.verify_two_factor")) + + user.last_login = utcnow() + db.session.commit() + login_user(user) + clear_pending_two_factor() + return redirect(url_for("main.dashboard")) return render_template("login.html") -@login_bp.route('/logout', methods=['POST']) + +@login_bp.route("/login/verify", methods=["GET", "POST"]) +def verify_two_factor(): + if current_user.is_authenticated: + return redirect(url_for("main.dashboard")) + + user = get_pending_two_factor_user() + + if user is None: + flash("Your verification session expired. Please log in again.", "error") + return redirect(url_for("login.login")) + + if request.method == "POST": + code = (request.form.get("code") or "").strip() + + if not code.isdigit() or len(code) != TWO_FACTOR_CODE_DIGITS: + return render_template( + "two_factor_verify.html", + error="Please enter the 6-digit verification code.", + ) + + attempts = int(session.get("pending_2fa_attempts", 0)) + + if attempts >= MAX_TWO_FACTOR_ATTEMPTS: + clear_pending_two_factor(user) + db.session.commit() + flash("Too many invalid 2FA attempts. Please log in again.", "error") + return redirect(url_for("login.login")) + + if not user.two_factor_secret: + clear_pending_two_factor() + flash("Authenticator app 2FA is not configured for this account.", "error") + return redirect(url_for("login.login")) + + if not verify_totp_code(user.two_factor_secret, code): + attempts += 1 + session["pending_2fa_attempts"] = attempts + + if attempts >= MAX_TWO_FACTOR_ATTEMPTS: + clear_pending_two_factor(user) + db.session.commit() + flash("Too many invalid 2FA attempts. Please log in again.", "error") + return redirect(url_for("login.login")) + + remaining_attempts = MAX_TWO_FACTOR_ATTEMPTS - attempts + return render_template( + "two_factor_verify.html", + error=f"Invalid authenticator code. You have {remaining_attempts} attempt(s) left.", + ) + + clear_pending_two_factor(user) + user.last_login = utcnow() + db.session.commit() + login_user(user) + flash("Two-factor authentication successful.", "success") + return redirect(url_for("main.dashboard")) + + return render_template("two_factor_verify.html") + + +@login_bp.route("/login/settings", methods=["GET", "POST"]) +@login_required +def login_settings(): + if request.method == "POST": + action = request.form.get("action") + + if action == "start_two_factor_setup": + if current_user.two_factor_enabled: + return render_login_settings(error="Authenticator app 2FA is already enabled.") + + secret = generate_two_factor_secret() + session["pending_2fa_setup_user_id"] = current_user.id + session["pending_2fa_setup_secret"] = secret + flash("Add the secret to your authenticator app, then confirm it with the current 6-digit code.", "info") + return redirect(url_for("login.login_settings")) + + if action == "confirm_two_factor_setup": + pending_secret = get_pending_two_factor_setup_secret(current_user) + + if not pending_secret: + return render_login_settings(error="Start the authenticator setup first.") + + code = (request.form.get("code") or "").strip() + + if not verify_totp_code(pending_secret, code): + return render_login_settings(error="Invalid authenticator code. Please try the current code again.") + + current_user.two_factor_secret = pending_secret + current_user.two_factor_enabled = True + clear_pending_two_factor_setup() + db.session.commit() + flash("Authenticator app 2FA enabled.", "success") + return redirect(url_for("login.login_settings")) + + if action == "cancel_two_factor_setup": + clear_pending_two_factor_setup() + flash("Authenticator app setup cancelled.", "info") + return redirect(url_for("login.login_settings")) + + if action == "disable_two_factor": + current_user.two_factor_enabled = False + current_user.two_factor_secret = None + clear_pending_two_factor_setup() + db.session.commit() + flash("Authenticator app 2FA disabled.", "success") + return redirect(url_for("login.login_settings")) + + if action == "password": + current_password = request.form.get("current_password") or "" + new_password = request.form.get("new_password") or "" + confirm_password = request.form.get("confirm_password") or "" + + if not check_password_hash(current_user.password, current_password): + return render_login_settings(error="Your current password is incorrect.") + + password_error = validate_password(new_password) + + if password_error: + return render_login_settings(error=password_error) + + if new_password != confirm_password: + return render_login_settings(error="The new passwords do not match.") + + current_user.password = generate_password_hash(new_password) + db.session.commit() + flash("Password updated successfully.", "success") + return redirect(url_for("login.login_settings")) + + return render_login_settings(error="Unknown settings action.") + + return render_login_settings() + + +@login_bp.route("/logout", methods=["POST"]) @login_required def logout(): + clear_pending_two_factor() + clear_pending_two_factor_setup() logout_user() - flash('You have been logged out', 'info') - return redirect(url_for('login.login')) + flash("You have been logged out.", "info") + return redirect(url_for("login.login")) diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index c3ca90f..0e33aad 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -24,6 +24,29 @@ body { text-decoration: underline; } +.nav-right { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.nav-logout-form { + margin: 0 0 0 15px; +} + +.nav-link-button { + padding: 0; + border: none; + background: none; + color: white; + font: inherit; + cursor: pointer; +} + +.nav-link-button:hover { + text-decoration: underline; +} + /* πŸ“¦ Layout */ .container { max-width: 900px; @@ -40,6 +63,33 @@ body { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } +.flash-stack { + display: grid; + gap: 12px; + margin-bottom: 18px; +} + +.flash-message { + margin: 0; + padding: 12px 14px; + border-radius: 10px; +} + +.flash-message.success { + color: #166534; + background: #dcfce7; +} + +.flash-message.info { + color: #1d4ed8; + background: #dbeafe; +} + +.flash-message.error { + color: #b91c1c; + background: #fee2e2; +} + .game-card { text-align: center; } @@ -305,6 +355,21 @@ body { margin: 0; } +.inline-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.inline-actions form { + margin: 0; +} + +.inline-actions .btn { + margin: 0; +} + .btn.ghost { background: #e2e8f0; color: #1e293b; @@ -337,4 +402,8 @@ body { .navbar { padding: 15px 18px; } + + .nav-logout-form { + margin-left: 0; + } } diff --git a/app/static/css/login.css b/app/static/css/login.css index 2035741..21554a9 100644 --- a/app/static/css/login.css +++ b/app/static/css/login.css @@ -1,7 +1,12 @@ :root { - --primary-bg-color: #f4f6f8; - --secondary-bg-color: white; - --nav-bg-color: #333; + --nav-bg-color: #172033; + --panel-bg-color: rgba(255, 255, 255, 0.18); + --panel-border-color: rgba(255, 255, 255, 0.24); + --text-color: #172033; + --muted-text-color: #475569; + --success-color: #15803d; + --info-color: #1d4ed8; + --error-color: #b91c1c; } @font-face { @@ -18,24 +23,30 @@ body { margin: 0; min-height: 100vh; - display: grid; - place-items: center; + color: var(--text-color); font-family: "Geist Sans", system-ui, sans-serif; - - background: linear-gradient(135deg, #c6dff4, #7c8384); + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.45), transparent 32%), + linear-gradient(135deg, #d2ecff 0%, #b8c6db 48%, #7f8ea3 100%); } - nav { width: 100%; - background: var(--nav-bg-color); + min-height: 68px; padding: 15px 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + background: var(--nav-bg-color); } nav ul { + margin: 0; + padding: 0; list-style: none; display: flex; - justify-content: flex-end; + flex-wrap: wrap; gap: 20px; } @@ -46,28 +57,68 @@ nav a { } nav a:hover { - color: #4facfe; + color: #93c5fd; } -/* Center login section */ -main { - min-height: calc(100vh - 60px); - /* full screen minus navbar */ +.nav-form { + margin: 0; + padding: 0; + width: auto; + background: none; + border: 0; + box-shadow: none; + backdrop-filter: none; +} + +.nav-button { + margin: 0; + padding: 0; + background: none; + border: 0; + color: white; + font: inherit; + cursor: pointer; +} + +.nav-button:hover { + transform: none; + box-shadow: none; + color: #93c5fd; +} + +.auth-shell { + min-height: calc(100vh - 68px); display: flex; - flex-direction: column; - justify-content: center; align-items: center; + justify-content: center; + padding: 32px 16px; } -/* Title */ -main h1 { - margin-bottom: 20px; +.settings-shell { + align-items: flex-start; } -main { +.auth-panel { + width: min(420px, 100%); + padding: 34px; + border-radius: 20px; + background: var(--panel-bg-color); + border: 1px solid var(--panel-border-color); + backdrop-filter: blur(16px); + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18); animation: fadeIn 0.8s ease; } +.settings-panel { + width: min(900px, 100%); +} + +.settings-grid { + display: grid; + gap: 24px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + @keyframes fadeIn { from { opacity: 0; @@ -80,72 +131,167 @@ main { } } - -/* Form box */ -form { - background: rgba(255, 255, 255, 0.15); - backdrop-filter: blur(12px); - padding: 35px; - border-radius: 12px; - width: 320px; - display: flex; - flex-direction: column; - - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.2); +h1, +h2 { + margin-top: 0; } +.muted { + margin: 0 0 20px; + color: var(--muted-text-color); + line-height: 1.5; +} + +form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 18px; + padding: 24px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.22); +} -/* Labels */ label { - margin-top: 10px; - margin-bottom: 5px; + margin-top: 8px; font-size: 14px; + font-weight: 600; } -/* Inputs */ -form { - background: rgba(255, 255, 255, 0.15); - backdrop-filter: blur(12px); - padding: 35px; - border-radius: 12px; - width: 320px; +input { + width: 100%; + padding: 12px 14px; + border: 1px solid rgba(71, 85, 105, 0.25); + border-radius: 10px; + font: inherit; + background: rgba(255, 255, 255, 0.86); + color: var(--text-color); +} + +input:focus { + outline: 2px solid rgba(59, 130, 246, 0.35); + border-color: rgba(59, 130, 246, 0.5); +} + +.field-hint { + margin: 0; + font-size: 13px; + color: var(--muted-text-color); +} + +.checkbox-row { display: flex; - flex-direction: column; - - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.2); + align-items: center; + gap: 10px; + margin: 10px 0 6px; } +.checkbox-row input { + width: auto; +} -/* Button */ button { - margin-top: 20px; - padding: 12px; + margin-top: 14px; + padding: 12px 16px; border: none; - background: linear-gradient(135deg, #e9eff5, #daebf0); - color: rgb(0, 0, 0); - border-radius: 8px; + border-radius: 10px; + background: linear-gradient(135deg, #eff6ff, #dbeafe); + color: #172033; cursor: pointer; font-size: 16px; - - transition: all 0.2s ease; + font-weight: 600; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; } button:hover { transform: translateY(-2px); - box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18); } button:active { - transform: scale(0.97); + transform: scale(0.98); } - -/* Error message */ -.error { - color: red; - margin-bottom: 15px; +.button-row { + display: flex; + flex-wrap: wrap; + gap: 12px; } -/* button anim */ \ No newline at end of file +.button-row button { + flex: 1 1 160px; +} + +.secondary-button { + background: linear-gradient(135deg, #e2e8f0, #cbd5e1); +} + +.message { + margin: 0 0 14px; + padding: 12px 14px; + border-radius: 10px; + font-size: 14px; +} + +.message.error { + color: var(--error-color); + background: rgba(254, 226, 226, 0.92); +} + +.message.success { + color: var(--success-color); + background: rgba(220, 252, 231, 0.92); +} + +.message.info { + color: var(--info-color); + background: rgba(219, 234, 254, 0.92); +} + +.qr-panel { + display: flex; + justify-content: center; + margin: 8px 0 6px; + padding: 18px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.38); + border: 1px solid rgba(255, 255, 255, 0.24); +} + +.qr-code { + display: flex; + align-items: center; + justify-content: center; + min-width: 220px; + min-height: 220px; + padding: 10px; + border-radius: 14px; + background: #ffffff; + color: var(--muted-text-color); + text-align: center; +} + +.qr-code img { + display: block; +} + +@media (max-width: 640px) { + nav { + padding: 16px 20px; + flex-direction: column; + align-items: flex-start; + } + + .auth-shell { + padding: 20px 12px 28px; + } + + .auth-panel { + padding: 24px 18px; + } + + form { + padding: 18px; + } +} diff --git a/app/static/js/login.js b/app/static/js/login.js index 7b22fe2..d916419 100644 --- a/app/static/js/login.js +++ b/app/static/js/login.js @@ -1,32 +1,69 @@ -const button = document.querySelector(".magnetic"); +const magneticButtons = document.querySelectorAll(".magnetic"); -button.addEventListener("mousemove", (e) => { - const rect = button.getBoundingClientRect(); - const x = e.clientX - rect.left - rect.width / 2; - const y = e.clientY - rect.top - rect.height / 2; +magneticButtons.forEach((button) => { + button.addEventListener("mousemove", (event) => { + const rect = button.getBoundingClientRect(); + const x = event.clientX - rect.left - rect.width / 2; + const y = event.clientY - rect.top - rect.height / 2; - button.style.transform = `translate(${x * 0.2}px, ${y * 0.2}px)`; -}); + button.style.transform = `translate(${x * 0.15}px, ${y * 0.15}px)`; + }); -button.addEventListener("mouseleave", () => { - button.style.transform = "translate(0,0)"; -}); - -const inputs = document.querySelectorAll("input"); - -inputs.forEach(input => { - input.addEventListener("input", () => { - input.style.boxShadow = "0 0 8px rgba(79,172,254,0.6)"; - - setTimeout(() => { - input.style.boxShadow = ""; - }, 300); + button.addEventListener("mouseleave", () => { + button.style.transform = "translate(0, 0)"; }); }); -const form = document.querySelector("form"); +document.querySelectorAll("input").forEach((input) => { + input.addEventListener("input", () => { + input.style.boxShadow = "0 0 8px rgba(59, 130, 246, 0.4)"; -form.addEventListener("submit", () => { - button.innerText = "Logging in..."; - button.style.opacity = "0.7"; + window.clearTimeout(input._loginGlowTimeout); + input._loginGlowTimeout = window.setTimeout(() => { + input.style.boxShadow = ""; + }, 250); + }); +}); + +document.querySelectorAll("form").forEach((form) => { + form.addEventListener("submit", (event) => { + const submitter = event.submitter; + + if (!submitter || submitter.dataset.loadingLabelApplied === "true") { + return; + } + + const loadingLabel = submitter.dataset.loadingLabel; + + if (loadingLabel) { + submitter.dataset.loadingLabelApplied = "true"; + submitter.dataset.originalLabel = submitter.innerText; + submitter.innerText = loadingLabel; + submitter.style.opacity = "0.7"; + } + }); +}); + +document.querySelectorAll(".qr-code[data-qr-value]").forEach((container) => { + const qrValue = container.dataset.qrValue; + + if (!qrValue) { + return; + } + + if (typeof window.QRCode !== "function") { + container.textContent = "QR preview could not be loaded."; + return; + } + + container.innerHTML = ""; + + new window.QRCode(container, { + text: qrValue, + width: 220, + height: 220, + colorDark: "#172033", + colorLight: "#ffffff", + correctLevel: window.QRCode.CorrectLevel.M + }); }); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 1c82d34..0d491d5 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -6,24 +6,33 @@ Dashboard - + - -
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} -

πŸ‘‹ Willkommen, {{ current_user.username }}

@@ -31,18 +40,16 @@

ID: {{ current_user.id }}

Username: {{ current_user.username }}

- {% if current_user.email %} -

Email: {{ current_user.email }}

- {% else %} -

Email: Not set

- {% endif %} - +

2FA: {% if current_user.two_factor_enabled %}Authenticator app enabled{% else %}Disabled{% endif %}

Erstellt: {{ current_user.created_at }}

-
- -
+
+ Open login settings +
+ +
+
@@ -51,8 +58,6 @@
- -

β™ŸοΈ Damen- und Tuerme-Spiel

Aktuelle Brettgroesse: 8 x

- + diff --git a/app/templates/login.html b/app/templates/login.html index 00152b2..3afe9fa 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -5,35 +5,47 @@ Login - - - + -
-

Login to your account

+
+
+

Login to your account

+

If two-factor authentication is enabled, enter the current 6-digit code from your authenticator app after the password check.

- {% if error %} -

{{ error }}

- {% endif %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endif %} + {% endwith %} -
- - - - - -
+ {% if error %} +

{{ error }}

+ {% endif %} + +
+ + + + + + + +
+
- + + - \ No newline at end of file + diff --git a/app/templates/login_settings.html b/app/templates/login_settings.html new file mode 100644 index 0000000..b4faf67 --- /dev/null +++ b/app/templates/login_settings.html @@ -0,0 +1,123 @@ + + + + + + + Login Settings + + + + + + + +
+
+

Login settings

+

Manage authenticator app 2FA and your password without changing the registration flow.

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endif %} + {% endwith %} + + {% if error %} +

{{ error }}

+ {% endif %} + +
+ {% if current_user.two_factor_enabled %} +
+ + +

Authenticator app 2FA

+

2FA is currently enabled for this account.

+

Use Google Authenticator, Microsoft Authenticator, Authy, or another TOTP app to generate your 6-digit login codes.

+ + +
+ {% elif pending_two_factor_secret %} +
+ + +

Finish 2FA setup

+

Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.

+ +
+
+
+

If scanning does not work, you can still use the manual setup key below.

+ + + + + + +

This URI is used to generate the QR setup and can be copied as a fallback for compatible apps.

+ + + + +
+ +
+
+ +
+ + +

Cancel setup

+

Cancel the current authenticator setup if you want to restart with a new secret.

+ + +
+ {% else %} +
+ + +

Authenticator app 2FA

+

Protect your account with 6-digit codes from an authenticator app.

+

After starting setup, you will get a QR code to scan and then confirm once with a live code.

+ + +
+ {% endif %} + +
+ + +

Change password

+ + + + + + + + + + + +
+
+
+
+ + + + + diff --git a/app/templates/sign_up.html b/app/templates/sign_up.html index 88adee1..e12576f 100644 --- a/app/templates/sign_up.html +++ b/app/templates/sign_up.html @@ -5,7 +5,7 @@ Sign Up - + @@ -15,21 +15,38 @@
  • Create account
  • -
    -

    Create an account

    - {% if error %} -

    {{ error }}

    - {% endif %} +
    +
    +

    Create an account

    +

    Create your account here and enable authenticator app 2FA later in your login settings.

    -
    - - - - - -
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +

    {{ message }}

    + {% endfor %} + {% endif %} + {% endwith %} + + {% if error %} +

    {{ error }}

    + {% endif %} + +
    + + + + + +

    Use at least 8 characters.

    + + +
    +
    + + - \ No newline at end of file + diff --git a/app/templates/two_factor_verify.html b/app/templates/two_factor_verify.html new file mode 100644 index 0000000..e5af10b --- /dev/null +++ b/app/templates/two_factor_verify.html @@ -0,0 +1,49 @@ + + + + + + + Verify Login + + + + + + +
    +
    +

    Verify your login

    +

    Open your authenticator app and enter the current 6-digit code.

    + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +

    {{ message }}

    + {% endfor %} + {% endif %} + {% endwith %} + + {% if error %} +

    {{ error }}

    + {% endif %} + +
    + + +

    Codes refresh automatically every 30 seconds in your authenticator app.

    + + +
    +
    +
    + + + + + diff --git a/database_setup.sql b/database_setup.sql index 161cd8b..216606d 100644 --- a/database_setup.sql +++ b/database_setup.sql @@ -1,16 +1,28 @@ CREATE TABLE users ( - id INT AUTO_INCREMENT PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(20) NOT NULL UNIQUE, - password VARCHAR(80) NOT NULL, - email VARCHAR(120) NOT NULL UNIQUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + password VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME, + two_factor_enabled BOOLEAN NOT NULL DEFAULT 0, + two_factor_secret VARCHAR(64) +); + +CREATE TABLE login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address VARCHAR(45) NOT NULL, + username VARCHAR(80) NOT NULL, + failed_attempts INTEGER NOT NULL DEFAULT 0, + locked_until DATETIME, + last_failed_at DATETIME, + CONSTRAINT uq_login_attempt_ip_username UNIQUE (ip_address, username) ); CREATE TABLE game_state ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, - board TEXT NOT NULL, -- JSON String (z.B. "[0,4,7,5,2,6,1,3]") - mode TEXT NOT NULL, -- "queens" oder "rooks" + board TEXT NOT NULL, + mode TEXT NOT NULL, board_size INTEGER NOT NULL DEFAULT 8, save_name VARCHAR(80) NOT NULL DEFAULT 'Unnamed Save', save_note TEXT NOT NULL DEFAULT '',