Merge pull request #14 from ScriptSorcerer20/3-improved-login
implemented optional authentification app and stricter password and u…
This commit is contained in:
+25
-1
@@ -2,28 +2,52 @@ from flask import Flask
|
|||||||
import os
|
import os
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
login_manager = LoginManager()
|
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():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db")
|
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["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)
|
db.init_app(app)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
login_manager.login_view = "login.login"
|
login_manager.login_view = "login.login"
|
||||||
|
login_manager.login_message_category = "info"
|
||||||
|
|
||||||
from .models.user import User
|
from .models.user import User
|
||||||
from .models.gamestate import GameState
|
from .models.gamestate import GameState
|
||||||
|
from .models.login_attempt import LoginAttempt
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
apply_startup_migrations()
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"<LoginAttempt {self.ip_address}:{self.username}>"
|
||||||
+4
-2
@@ -1,15 +1,17 @@
|
|||||||
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):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
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) # <- größer!
|
password = db.Column(db.String(255), nullable=False)
|
||||||
email = db.Column(db.String(120), unique=True, nullable=True)
|
|
||||||
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_secret = db.Column(db.String(64))
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<User {self.username}>"
|
return f"<User {self.username}>"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+462
-23
@@ -1,30 +1,291 @@
|
|||||||
from flask import Blueprint, render_template, request, url_for, redirect, flash
|
import base64
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
import binascii
|
||||||
from flask_login import login_user, login_required, logout_user
|
import hashlib
|
||||||
from ..models.user import User
|
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 .. import db
|
||||||
|
from ..models.login_attempt import LoginAttempt
|
||||||
|
from ..models.user import User
|
||||||
|
|
||||||
login_bp = Blueprint("login", __name__)
|
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"])
|
@login_bp.route("/register", methods=["GET", "POST"])
|
||||||
def register():
|
def register():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username")
|
username = normalize_username(request.form.get("username"))
|
||||||
password = request.form.get("password")
|
password = request.form.get("password") or ""
|
||||||
|
|
||||||
if User.query.filter_by(username=username).first():
|
username_error = validate_username(username)
|
||||||
return render_template("sign_up.html", error="Username already taken!")
|
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(
|
if password_error:
|
||||||
username=username,
|
return render_template("sign_up.html", error=password_error, username=username)
|
||||||
password=hashed_password
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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.add(new_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Account created successfully. You can log in now.", "success")
|
||||||
return redirect(url_for("login.login"))
|
return redirect(url_for("login.login"))
|
||||||
|
|
||||||
return render_template("sign_up.html")
|
return render_template("sign_up.html")
|
||||||
@@ -32,23 +293,201 @@ def register():
|
|||||||
|
|
||||||
@login_bp.route("/login", methods=["GET", "POST"])
|
@login_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username")
|
username = normalize_username(request.form.get("username"))
|
||||||
password = request.form.get("password")
|
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):
|
locked_until = get_active_lock(username)
|
||||||
login_user(user)
|
|
||||||
return redirect(url_for("main.dashboard"))
|
|
||||||
|
|
||||||
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")
|
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
|
@login_required
|
||||||
def logout():
|
def logout():
|
||||||
|
clear_pending_two_factor()
|
||||||
|
clear_pending_two_factor_setup()
|
||||||
logout_user()
|
logout_user()
|
||||||
flash('You have been logged out', 'info')
|
flash("You have been logged out.", "info")
|
||||||
return redirect(url_for('login.login'))
|
return redirect(url_for("login.login"))
|
||||||
|
|||||||
@@ -24,6 +24,29 @@ body {
|
|||||||
text-decoration: underline;
|
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 */
|
/* 📦 Layout */
|
||||||
.container {
|
.container {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
@@ -40,6 +63,33 @@ body {
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
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 {
|
.game-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -305,6 +355,21 @@ body {
|
|||||||
margin: 0;
|
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 {
|
.btn.ghost {
|
||||||
background: #e2e8f0;
|
background: #e2e8f0;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
@@ -337,4 +402,8 @@ body {
|
|||||||
.navbar {
|
.navbar {
|
||||||
padding: 15px 18px;
|
padding: 15px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-logout-form {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+210
-64
@@ -1,7 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
--primary-bg-color: #f4f6f8;
|
--nav-bg-color: #172033;
|
||||||
--secondary-bg-color: white;
|
--panel-bg-color: rgba(255, 255, 255, 0.18);
|
||||||
--nav-bg-color: #333;
|
--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 {
|
@font-face {
|
||||||
@@ -18,24 +23,30 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
color: var(--text-color);
|
||||||
place-items: center;
|
|
||||||
font-family: "Geist Sans", system-ui, sans-serif;
|
font-family: "Geist Sans", system-ui, sans-serif;
|
||||||
|
background:
|
||||||
background: linear-gradient(135deg, #c6dff4, #7c8384);
|
radial-gradient(circle at top left, rgba(255, 255, 255, 0.45), transparent 32%),
|
||||||
|
linear-gradient(135deg, #d2ecff 0%, #b8c6db 48%, #7f8ea3 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--nav-bg-color);
|
min-height: 68px;
|
||||||
padding: 15px 30px;
|
padding: 15px 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
background: var(--nav-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
nav ul {
|
nav ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
flex-wrap: wrap;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,28 +57,68 @@ nav a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nav a:hover {
|
nav a:hover {
|
||||||
color: #4facfe;
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Center login section */
|
.nav-form {
|
||||||
main {
|
margin: 0;
|
||||||
min-height: calc(100vh - 60px);
|
padding: 0;
|
||||||
/* full screen minus navbar */
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Title */
|
.settings-shell {
|
||||||
main h1 {
|
align-items: flex-start;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
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 {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -80,72 +131,167 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
/* Form box */
|
h2 {
|
||||||
form {
|
margin-top: 0;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
label {
|
||||||
margin-top: 10px;
|
margin-top: 8px;
|
||||||
margin-bottom: 5px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inputs */
|
input {
|
||||||
form {
|
width: 100%;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
padding: 12px 14px;
|
||||||
backdrop-filter: blur(12px);
|
border: 1px solid rgba(71, 85, 105, 0.25);
|
||||||
padding: 35px;
|
border-radius: 10px;
|
||||||
border-radius: 12px;
|
font: inherit;
|
||||||
width: 320px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
margin: 10px 0 6px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-row input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Button */
|
|
||||||
button {
|
button {
|
||||||
margin-top: 20px;
|
margin-top: 14px;
|
||||||
padding: 12px;
|
padding: 12px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
background: linear-gradient(135deg, #e9eff5, #daebf0);
|
border-radius: 10px;
|
||||||
color: rgb(0, 0, 0);
|
background: linear-gradient(135deg, #eff6ff, #dbeafe);
|
||||||
border-radius: 8px;
|
color: #172033;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
transition: all 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
transform: translateY(-2px);
|
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 {
|
button:active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
/* Error message */
|
display: flex;
|
||||||
.error {
|
flex-wrap: wrap;
|
||||||
color: red;
|
gap: 12px;
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* button anim */
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+61
-24
@@ -1,32 +1,69 @@
|
|||||||
const button = document.querySelector(".magnetic");
|
const magneticButtons = document.querySelectorAll(".magnetic");
|
||||||
|
|
||||||
button.addEventListener("mousemove", (e) => {
|
magneticButtons.forEach((button) => {
|
||||||
const rect = button.getBoundingClientRect();
|
button.addEventListener("mousemove", (event) => {
|
||||||
const x = e.clientX - rect.left - rect.width / 2;
|
const rect = button.getBoundingClientRect();
|
||||||
const y = e.clientY - rect.top - rect.height / 2;
|
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.addEventListener("mouseleave", () => {
|
||||||
button.style.transform = "translate(0,0)";
|
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
window.clearTimeout(input._loginGlowTimeout);
|
||||||
button.innerText = "Logging in...";
|
input._loginGlowTimeout = window.setTimeout(() => {
|
||||||
button.style.opacity = "0.7";
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,24 +6,33 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Dashboard</title>
|
<title>Dashboard</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="../static/css/dashboard.css">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- 🔝 Navbar -->
|
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-left">♛ 8-Queens App</div>
|
<div class="nav-left">♛ 8-Queens App</div>
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
||||||
<a href="{{ url_for('login.logout') }}">Logout</a>
|
<a href="{{ url_for('login.login_settings') }}">Login settings</a>
|
||||||
|
<form action="{{ url_for('login.logout') }}" method="post" class="nav-logout-form">
|
||||||
|
<button class="nav-link-button" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 📦 Container -->
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-stack">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<p class="flash-message {{ category }}">{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<!-- 👤 User Card -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>👋 Willkommen, {{ current_user.username }}</h2>
|
<h2>👋 Willkommen, {{ current_user.username }}</h2>
|
||||||
|
|
||||||
@@ -31,18 +40,16 @@
|
|||||||
<p><strong>ID:</strong> {{ current_user.id }}</p>
|
<p><strong>ID:</strong> {{ current_user.id }}</p>
|
||||||
<p><strong>Username:</strong> {{ current_user.username }}</p>
|
<p><strong>Username:</strong> {{ current_user.username }}</p>
|
||||||
|
|
||||||
{% if current_user.email %}
|
<p><strong>2FA:</strong> {% if current_user.two_factor_enabled %}Authenticator app enabled{% else %}Disabled{% endif %}</p>
|
||||||
<p><strong>Email:</strong> {{ current_user.email }}</p>
|
|
||||||
{% else %}
|
|
||||||
<p><strong>Email:</strong> Not set</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p><strong>Erstellt:</strong> {{ current_user.created_at }}</p>
|
<p><strong>Erstellt:</strong> {{ current_user.created_at }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action="{{ url_for('login.logout') }}" method="post">
|
<div class="inline-actions">
|
||||||
<button class="btn danger" type="submit">Logout</button>
|
<a class="btn secondary" href="{{ url_for('login.login_settings') }}">Open login settings</a>
|
||||||
</form>
|
<form action="{{ url_for('login.logout') }}" method="post">
|
||||||
|
<button class="btn danger" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mode-switch">
|
<div class="mode-switch">
|
||||||
@@ -51,8 +58,6 @@
|
|||||||
<button class="btn secondary" onclick="toggleSidebar()" id="btnSidebar">Board Settings</button>
|
<button class="btn secondary" onclick="toggleSidebar()" id="btnSidebar">Board Settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- ♟️ Game Card -->
|
|
||||||
<div class="card game-card">
|
<div class="card game-card">
|
||||||
<h2>♟️ Damen- und Tuerme-Spiel</h2>
|
<h2>♟️ Damen- und Tuerme-Spiel</h2>
|
||||||
<p class="board-size-info">Aktuelle Brettgroesse: <span id="boardSizeValue">8</span> x <span
|
<p class="board-size-info">Aktuelle Brettgroesse: <span id="boardSizeValue">8</span> x <span
|
||||||
@@ -165,7 +170,7 @@
|
|||||||
<div class="sidebar-backdrop" id="saveBackdrop" onclick="toggleSavePanel(false)"></div>
|
<div class="sidebar-backdrop" id="saveBackdrop" onclick="toggleSavePanel(false)"></div>
|
||||||
<div class="sidebar-backdrop" id="savePointsBackdrop" onclick="toggleSavePointsPanel(false)"></div>
|
<div class="sidebar-backdrop" id="savePointsBackdrop" onclick="toggleSavePointsPanel(false)"></div>
|
||||||
|
|
||||||
<script src="../static/js/dashboard.js"></script>
|
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+31
-19
@@ -5,35 +5,47 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Login</title>
|
<title>Login</title>
|
||||||
<link rel="stylesheet" href="../static/css/login.css">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||||
<link rel="stylesheet" href="../static/css/login.css">
|
|
||||||
</link>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ url_for('login.login') }}"><span>Login</span></a></li>
|
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
||||||
<li><a href="{{ url_for('login.register') }}"><span>Create account</span></a></li>
|
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main>
|
<main class="auth-shell">
|
||||||
<h1>Login to your account</h1>
|
<section class="auth-panel">
|
||||||
|
<h1>Login to your account</h1>
|
||||||
|
<p class="muted">If two-factor authentication is enabled, enter the current 6-digit code from your authenticator app after the password check.</p>
|
||||||
|
|
||||||
{% if error %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
<p class="error">{{ error }}</p>
|
{% if messages %}
|
||||||
{% endif %}
|
{% for category, message in messages %}
|
||||||
|
<p class="message {{ category }}">{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<form action="{{ url_for('login.login') }}" method="post">
|
{% if error %}
|
||||||
<label for="username">Username:</label>
|
<p class="message error">{{ error }}</p>
|
||||||
<input type="text" name="username" required />
|
{% endif %}
|
||||||
<label for="password">Password:</label>
|
|
||||||
<input type="password" name="password" required />
|
<form action="{{ url_for('login.login') }}" method="post">
|
||||||
<button class="magnetic" type="submit">Login</button>
|
<label for="username">Username</label>
|
||||||
</form>
|
<input id="username" type="text" name="username" maxlength="20" required value="{{ username or '' }}">
|
||||||
|
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" type="password" name="password" minlength="8" maxlength="128" required>
|
||||||
|
|
||||||
|
<button class="magnetic" type="submit" data-loading-label="Checking login...">Login</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script src="../static/js/login.js" defer></script>
|
|
||||||
|
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login Settings</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
|
||||||
|
<li><a href="{{ url_for('login.login_settings') }}">Login settings</a></li>
|
||||||
|
</ul>
|
||||||
|
<form action="{{ url_for('login.logout') }}" method="post" class="nav-form">
|
||||||
|
<button type="submit" class="nav-button">Logout</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="auth-shell settings-shell">
|
||||||
|
<section class="auth-panel settings-panel">
|
||||||
|
<h1>Login settings</h1>
|
||||||
|
<p class="muted">Manage authenticator app 2FA and your password without changing the registration flow.</p>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<p class="message {{ category }}">{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="message error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
{% if current_user.two_factor_enabled %}
|
||||||
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
|
<input type="hidden" name="action" value="disable_two_factor">
|
||||||
|
|
||||||
|
<h2>Authenticator app 2FA</h2>
|
||||||
|
<p class="field-hint">2FA is currently enabled for this account.</p>
|
||||||
|
<p class="field-hint">Use Google Authenticator, Microsoft Authenticator, Authy, or another TOTP app to generate your 6-digit login codes.</p>
|
||||||
|
|
||||||
|
<button class="secondary-button" type="submit" data-loading-label="Disabling 2FA...">Disable 2FA</button>
|
||||||
|
</form>
|
||||||
|
{% elif pending_two_factor_secret %}
|
||||||
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
|
<input type="hidden" name="action" value="confirm_two_factor_setup">
|
||||||
|
|
||||||
|
<h2>Finish 2FA setup</h2>
|
||||||
|
<p class="field-hint">Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.</p>
|
||||||
|
|
||||||
|
<div class="qr-panel">
|
||||||
|
<div class="qr-code" data-qr-value="{{ pending_two_factor_uri }}"></div>
|
||||||
|
</div>
|
||||||
|
<p class="field-hint">If scanning does not work, you can still use the manual setup key below.</p>
|
||||||
|
|
||||||
|
<label for="two_factor_secret">Manual setup key</label>
|
||||||
|
<input id="two_factor_secret" type="text" value="{{ pending_two_factor_secret }}" readonly>
|
||||||
|
|
||||||
|
<label for="two_factor_uri">Setup URI</label>
|
||||||
|
<input id="two_factor_uri" type="text" value="{{ pending_two_factor_uri }}" readonly>
|
||||||
|
<p class="field-hint">This URI is used to generate the QR setup and can be copied as a fallback for compatible apps.</p>
|
||||||
|
|
||||||
|
<label for="code">Authenticator code</label>
|
||||||
|
<input id="code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="magnetic" type="submit" data-loading-label="Enabling 2FA...">Confirm and enable</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
|
<input type="hidden" name="action" value="cancel_two_factor_setup">
|
||||||
|
|
||||||
|
<h2>Cancel setup</h2>
|
||||||
|
<p class="field-hint">Cancel the current authenticator setup if you want to restart with a new secret.</p>
|
||||||
|
|
||||||
|
<button class="secondary-button" type="submit">Cancel setup</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
|
<input type="hidden" name="action" value="start_two_factor_setup">
|
||||||
|
|
||||||
|
<h2>Authenticator app 2FA</h2>
|
||||||
|
<p class="field-hint">Protect your account with 6-digit codes from an authenticator app.</p>
|
||||||
|
<p class="field-hint">After starting setup, you will get a QR code to scan and then confirm once with a live code.</p>
|
||||||
|
|
||||||
|
<button class="magnetic" type="submit" data-loading-label="Starting setup...">Start setup</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
|
<input type="hidden" name="action" value="password">
|
||||||
|
|
||||||
|
<h2>Change password</h2>
|
||||||
|
|
||||||
|
<label for="current_password">Current password</label>
|
||||||
|
<input id="current_password" type="password" name="current_password" minlength="8" maxlength="128"
|
||||||
|
required>
|
||||||
|
|
||||||
|
<label for="new_password">New password</label>
|
||||||
|
<input id="new_password" type="password" name="new_password" minlength="8" maxlength="128" required>
|
||||||
|
|
||||||
|
<label for="confirm_password">Confirm new password</label>
|
||||||
|
<input id="confirm_password" type="password" name="confirm_password" minlength="8" maxlength="128"
|
||||||
|
required>
|
||||||
|
|
||||||
|
<button class="magnetic" type="submit" data-loading-label="Updating password...">Update password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
+31
-14
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Sign Up</title>
|
<title>Sign Up</title>
|
||||||
<link rel="stylesheet" href="../static/css/login.css">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -15,21 +15,38 @@
|
|||||||
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
|
||||||
<h1>Create an account</h1>
|
|
||||||
|
|
||||||
{% if error %}
|
<main class="auth-shell">
|
||||||
<p class="error">{{ error }}</p>
|
<section class="auth-panel">
|
||||||
{% endif %}
|
<h1>Create an account</h1>
|
||||||
|
<p class="muted">Create your account here and enable authenticator app 2FA later in your login settings.</p>
|
||||||
|
|
||||||
<form action="{{ url_for('login.register') }}" method="post">
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
<label for="username">Username:</label>
|
{% if messages %}
|
||||||
<input type="text" name="username" required />
|
{% for category, message in messages %}
|
||||||
<label for="password">Password:</label>
|
<p class="message {{ category }}">{{ message }}</p>
|
||||||
<input type="password" name="password" required />
|
{% endfor %}
|
||||||
<button type="submit">Sign Up</button>
|
{% endif %}
|
||||||
</form>
|
{% endwith %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="message error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form action="{{ url_for('login.register') }}" method="post">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" type="text" name="username" maxlength="20" required value="{{ username or '' }}">
|
||||||
|
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" type="password" name="password" minlength="8" maxlength="128" required>
|
||||||
|
<p class="field-hint">Use at least 8 characters.</p>
|
||||||
|
|
||||||
|
<button class="magnetic" type="submit" data-loading-label="Creating account...">Sign Up</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Verify Login</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
||||||
|
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="auth-shell">
|
||||||
|
<section class="auth-panel">
|
||||||
|
<h1>Verify your login</h1>
|
||||||
|
<p class="muted">Open your authenticator app and enter the current 6-digit code.</p>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<p class="message {{ category }}">{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="message error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form action="{{ url_for('login.verify_two_factor') }}" method="post">
|
||||||
|
<label for="code">Verification code</label>
|
||||||
|
<input id="code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required>
|
||||||
|
<p class="field-hint">Codes refresh automatically every 30 seconds in your authenticator app.</p>
|
||||||
|
|
||||||
|
<button class="magnetic" type="submit" data-loading-label="Verifying...">Verify</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
+18
-6
@@ -1,16 +1,28 @@
|
|||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username VARCHAR(20) NOT NULL UNIQUE,
|
username VARCHAR(20) NOT NULL UNIQUE,
|
||||||
password VARCHAR(80) NOT NULL,
|
password VARCHAR(255) NOT NULL,
|
||||||
email VARCHAR(120) NOT NULL UNIQUE,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
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 (
|
CREATE TABLE game_state (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
board TEXT NOT NULL, -- JSON String (z.B. "[0,4,7,5,2,6,1,3]")
|
board TEXT NOT NULL,
|
||||||
mode TEXT NOT NULL, -- "queens" oder "rooks"
|
mode TEXT NOT NULL,
|
||||||
board_size INTEGER NOT NULL DEFAULT 8,
|
board_size INTEGER NOT NULL DEFAULT 8,
|
||||||
save_name VARCHAR(80) NOT NULL DEFAULT 'Unnamed Save',
|
save_name VARCHAR(80) NOT NULL DEFAULT 'Unnamed Save',
|
||||||
save_note TEXT NOT NULL DEFAULT '',
|
save_note TEXT NOT NULL DEFAULT '',
|
||||||
|
|||||||
Reference in New Issue
Block a user