From 9cc1a222320f58eef897adb97b19a95e490c6302 Mon Sep 17 00:00:00 2001 From: ScriptSorcerer20 Date: Wed, 1 Apr 2026 10:38:29 +0200 Subject: [PATCH] implemented optional authentification app and stricter password and user rules. Also against brute force. --- app/__init__.py | 26 +- app/__pycache__/__init__.cpython-311.pyc | Bin 2477 -> 4223 bytes app/__pycache__/__init__.cpython-312.pyc | Bin 4439 -> 3616 bytes .../__pycache__/login_attempt.cpython-311.pyc | Bin 0 -> 1619 bytes .../__pycache__/login_attempt.cpython-312.pyc | Bin 0 -> 1569 bytes app/models/__pycache__/user.cpython-311.pyc | Bin 1475 -> 1657 bytes app/models/__pycache__/user.cpython-312.pyc | Bin 0 -> 1598 bytes app/models/login_attempt.py | 19 + app/models/user.py | 6 +- .../__pycache__/login_routes.cpython-311.pyc | Bin 3515 -> 25819 bytes .../__pycache__/login_routes.cpython-312.pyc | Bin 0 -> 22820 bytes app/routes/login_routes.py | 485 +++++++++++++++++- app/static/css/dashboard.css | 69 +++ app/static/css/login.css | 274 +++++++--- app/static/js/login.js | 85 ++- app/templates/dashboard.html | 39 +- app/templates/login.html | 50 +- app/templates/login_settings.html | 123 +++++ app/templates/sign_up.html | 45 +- app/templates/two_factor_verify.html | 49 ++ database_setup.sql | 24 +- 21 files changed, 1124 insertions(+), 170 deletions(-) create mode 100644 app/models/__pycache__/login_attempt.cpython-311.pyc create mode 100644 app/models/__pycache__/login_attempt.cpython-312.pyc create mode 100644 app/models/__pycache__/user.cpython-312.pyc create mode 100644 app/models/login_attempt.py create mode 100644 app/routes/__pycache__/login_routes.cpython-312.pyc create mode 100644 app/templates/login_settings.html create mode 100644 app/templates/two_factor_verify.html 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 c75a2cb53ecd25c07bde4182f6d7f95ab4362cfc..b44b30f3c87876bb102c299c4b8783434097917b 100644 GIT binary patch literal 4223 zcma(UTWlN0agRKbM^X|gS+T80Oj|w2q?%n`oc3>#$95h|DA-IwzeJvLK#*468h?tc@mJfV1zwxjFl7G+D~plN>eJj0%EobKL(DcRjwT9zk=4t3cH$Fk9j10!dY%3 z;HG!60)nH=D~FL;K1Xp@j^=ESEIG@HWzDwN8fJ+wx_){J&YiR7=oRYV~^PVLF`<`9C3WyQTXCwkI>foS2r0|NQ=!1IhDK z@#M`pfu9HmmQAHo3d?q9mtHyt zWL`;T#O#IT<3JOZSXoI(%3PKeQ!`RRNoCS<_kfT|CItCHca_-#d&})GW;j{NYJqv1hdqv`m1({ZgSSZE5W z4$_OdQ~%Pk(H~pykA1hG^}ko>e-AvXwY^^e8gp=~BV9avy=1d?xJn2Hl{r*$p*McM zarEN)(Tm#AaN%gUge=Ze`k>Wy+!=Y;QuMT{9Vb-}>6_lBPhA_{j&*OxH>aLBzk2KI zw={2G!P}>L`(8X_T&u1Pre&RJ(U`UZ)23E>=>TH7;e---%6rGXPat-E?09wLGr#m4 zQfi{tY&Guf9n)UHyJzfD>fngwX5Q00gE&aNV%AGx4$-O`;_CrY<3tCCBBNK?a0pUFC>~+2j1NQc6B!(jOt2%7 ztIG`>KOZz=bMOOx=@LFvBQ-G|8XRFq$HK#xh6h6v!(*}d1S}hg#{pyq$Hqp6Bka(` z#Jgj$=v91hJTe*?y%-taMX^Q`i@W6Fq0vZucp|dw7D)Bgt&f;arKdALkZev;^A!l& z@audB@>4tu*6r@ z3ZP0pMI?PNyvU@FDlj;1kDJ(XXSLr?=qgRheErPJ>Mt-FWPcEWi#i zkxCQrkVL{QW>`JFu`IS^WK3rh%1!J@+>i}`OGzZ@60YeKWMCH4vzb(yB-ZqFY6jau zF+M%7tBM)Dw1FezdSgS;D4DU#n9is81-!pZf_x3-G@Ar1XEM?fc8eK~7vyfDzHS=q zEn{X#)+gSTg5WwfzQ!f>?IO-OFo8`5~1l;zk}F`*#nc^JEO!xJO&65c0eph}R9 zYsDH9iP#l4l_|QN%7oJffuDiG0V;(X!W^HKh&vfqpcNBlUrdzM@2= z4R4bqLFxu*ZyP?Zo-&)2VnA(!Pqx7o1lMl5JfAUldsch$gT%`V_ix?1^`-P^;SaaI zxb^2n&3CTgJEwX23!eV`&{o&Ezn}cu*?$cDb>KTj9iPN%#- zflc4xM@vuI)wAztzM+C|h*0jVXmoqA@0`KaqtPddT_?6hiosrE642^Th6-szI#5+*;;V6LP=xm)Q3&Rb-(^z{aw$h=Zr0!+g@M?^y0;f9TNt}7Rq+AgiOPaR%G{8X?A4mR_S1E->u#j z?I@e+oJJoh&_`7INU@4LtI?eWx>Kb)x7-c+%W@N3hky1Tiv+FT2I)wzQ7iY3ryInBUlLZ3F9AEiBwkDZJ_nXB9WQ|lUz_?At6z(l#N*RHkp!|RlkZ7 zDWUWbqF>dQ%mBPR_*dtG6bAx{m+}`dl&lm*7106J+>6MW*Z+#>4b|L>=!k0WyDw3H delta 1026 zcmZuvOH30{6n!(DPUq9klom>K9LsOW3@@6nf+CU76 zg^-Zu#f3ZKLSxXlGjS~|qlqMFa14=+MiUn`YSOUL_XaB;eMxWcJ@=mbonN8HUg?3$ zB>?RPGw^cfnc%zX^65uf$O92jGdV4PxK0AsvD$3U1Z-HViVeC$^m zfZJ?oH-IGevs+>EV2Z0IQS7MA;g<|g6#!sWKz*DSy>WOY!}lM!wu?K6$DP#}g;xa4 zm6n!R3SeE-pkYpj&dDA{usP~cECPt}ETG=rXUk>LYHr@YnVMRt->oD1CH zc?&jN$feb$>k5ojRMLFu`hwjm>bEj`=^ES*?qScoz}GQ&H!`}#M}xDHqF#^a>I77c z$h3OX6k>z@BZH&y%Y#?ZOTM{vR@Ab|R3>W4FBEPjOqt9TU4{6cA4;Uvn2|74lotw7 zlUBw}hpy^+Dw{EFN!2iIsf-b(O~(u-#8V0saRMD*1BB+$d%6dO#Zy!ejf$=GcYq4w zF{%OG75ix@p-*DCPfTYORnwz{ZBb44A0&wj>!dIw%QwYDE*GE3W(@U)VG4K{?1?8d z4Hca&ln*^}`seCNoCm7Ol#W^PacxG;LDHSWv|+Z%UF?eu#s`K+tcTTF+!q&p2Vt#`O zZgrg}`fIs;gXutTT~XwePR{M{&XuO6rVT!@$p^}SvimKu%?T^6CD)?7EEnantqNPd zZzSr+-_U*15v@|G96>DeCkt5bru}%CrtF>Qm1LZFel9Q&w5UP&RGL4j6 J2LB3+{~KC6`o;hN diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc index ae97128424de79a8757e3b913bbd82e78df38ceb..a633a39a35340171b5f6e0bdb96e75fe9f8361aa 100644 GIT binary patch delta 1786 zcmZuy+i%lW7`GiKzU11_^^#=NO{G$=O=zJ571$+7UD}c+C`m_&G+9~gb}4oe*=`{b zRXYiZizcz^JOBa>BsQrV+FvkD(;k@6HfhNMYh;SV%O22P3Y#>IryaYrbb=%KJKy*F z&UZP-=Vz}4Zv|XGJDoHFN2UHt{wHF^)wc98w5}imBA^l?qgfO+bWFms7@RGVMaHwZ ziQ$q}wqPf3d~zbeXs3((Om12$D!f?8eI$tj6HO-LTqMCHk{Kp3 z8ILo=+(=|Ho?-echhCE__1a{rIIV~p^FbsP8;zuT4h*s^rh8wlrZ?@tHiK9ioVQJ4 ztPPwbx}7aX%lvdvnwJYaSSEr?stukDV~*$UT&c8|=ktZU#`ED)S#N`YROVGJr)cvf zUe3=dIW1o-s7e4v+2Nyp4aG9D?rFV&-K4Hjbx&}4>@nf`j9v5g*1WxSZ>UCu8bi^w zp+s#caqo0}=)~{-snvz6BexD*PgIxuQ&qC9>YsWXEHpYld}_4>=_8@a*5P!li$c z2xY%aU!>Q__8Qs#fb?&)B4qeq-9M#TVZYxyq6hJ5s2~XVlJeLp76ds@-2RqA9h%`c z$D9}ZK<^@P!FmUS0JuT>2>b}t1RJ^j zsL%5@g6^yy0tq~TX?ww4dl$9c1Gr^r4qqTM zAq!#h!(h_Ew9Fme>gnZt)&}y9J!lJ9afAapo#tXGF2f(=PF7rkxDeXBk)cy!89a1! zE@kxAG?z|~B@=usnLIYe@uQi{@nj-?Qg2If6Wm0UOTFT;#B*m}iAzT&xb#?ttJtOD zY`zdSrHBdIYa0m_|q9dcY}~CM(Z{hBNi4ECs17MQj6IK zFN><0n-%$KSai0ily$pY6hui48%fQKy5|Kl3!8&%(?q4mXrcK9yqPDr*`~r8E<2yM?ZaYEh7MMsU zqmeYn509nHx@H9>3?mgITp2KgxpNX~n;#42eZ>!rm!(T3WgG$qsE&b(JLO*Q=)76J zR$dKM4<4;|jDla?zEGpDe>rx3{FCvjyQhY;jnKfB$=2|<8hzWQ;RR3J!NErFz`2S0 zc+Ver=OeuL5x(y+<+$8&v7=7;o+6mFYn`MoJ1;svb+41meKPou6=Gn}bF`;x!*D-W z9k8RjSjWhK<;MZ*$RUDt>DC$JH=sb;Bg%fT>WM|o*KV-YJU??MUm?m~12A^ai`QiC^jr8iatVGA0ia!Z)$hC(7<0z7wR@6{{!d-=0^Yk literal 4439 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 n)bIe+{T|i#o2AOKcrAz!T6P(*X 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 0000000000000000000000000000000000000000..9aa4c7a64d7ea4238c9bb79d1b9a95b0b8fa5b6a GIT binary patch literal 1619 zcma)5&2Jl35Pxs|k+oxIV+WfWQrbfgnBppmK&eApDMq@haMKo`Sm?e~&yx?VeP zcM&NZeCQz(J@^m_R~V5)6TB2S{)kWkZMDp2e;}iY4F1zv78*CP_z5C0`T@~`nj{}~@0j31~rM8uwTxQ`zh%(oxi4MKhJ zdL+mGmM!kv1|#K>|LrODJV*UJWSGQAe1BwcT&q}45a~XWlVlXemT+SWl{L+nu`F7y zTJDI>HAVGQ^Ck>*xXQG$$~Kj|bn`F=+24gj(Xe%0E$Z~}2AmvnOczgN11!R&AwpMG zTZeQeWuH@~_q2nx-q-U^LV+b;s=uWdK(JH{S|iLGD%R4;K*l((nyDdL0<- zEdC0ugXGb-u(P1|{Xq|W7lO+M7w?T+dj7g+kMZ;|zLFJ~1fh(#j>DN$Up0C7+2i@r zbYZ!4$JS}#gXQ_cwW~K~3To+&l_OA+k+W>d$~0GH6=+4dpjNAea)nUcEDYS|LUq#> z6@}3%QH!h85Uim%oQ}^hL@5|9&9!;jbt;kGx_jz z^YlTgB~7)ZDMy;B&3Dpg>cwqsM|c!Hxb@RaYvt3{%Eu1y&(!ux(OD_B(xrB~-??M)k~jY=>`gH6 k%{4y))(v2cJ1Ef_e7a!?bLC(YD`I_K$QN7xD`oI*(e0N^(+v`?tTG$t5R1Q=jwf(+Ck1OoUNU||hlk-!zH9dPp95d&Tm z^O71mV8}p)zJM1NPkaE7TSDV!$P*BtK@2njLroL_kye_ebp`;gjh)tT2@AX84o||4 z)^%2FQ{NkSby6(tu>Abs&~Eq`C-R?hy8aO-dKS(Zy@asbj2H-N>ZCZ*&afNZX~nqvSD3!9Foa7 zevio|qz9sXhporC95pgJ&7%h-e=y4MZ!)E9xu&TvnB?FKra9nQeK@9yG{U(qOqO)l zWNjY&q> z8-U+}6~_JbY_Sz&mevY(TA4-sE_55HQu<-fWakx(9Knercuf_k!U8B?OkRRb%q5$b zFmg1g=W>@bSxij(@~N^fId+o6WFeA1gQIpiD1SC(|cB=~snzI;P>$Q^@CCuU|BA25?^ zIfls=M7JGu?Ctm7wTxAlOlK|Ip}JwQnYU^|I$gF|+viy^-vgRtANnRIe0WX5pNhT5 z$Qr1>anZZ=Yb3Frc$$2i+!!xT6eeE0UyF=+Q}y_TjYKh7NS0Ei@p5W2wLQCawemqN zp7K7cE9ckg)BNLnDOpOClbgxy@vXNi{Wax7@00q#@Ww*XC>Yzq^XRi^}q9LbZxNUzcNn1L^t&HoOEB82cK5dS@_z-h>;yHdFF2jF}&_D}deVm$x= literal 0 HcmV?d00001 diff --git a/app/models/__pycache__/user.cpython-311.pyc b/app/models/__pycache__/user.cpython-311.pyc index 0748aa0ac9ad887b021489ae3b80c092f71d8fda..bc150d66259ffb89918750d955e025a0dc589ad9 100644 GIT binary patch delta 576 zcmX@i{ga1xIWI340}xbQK9iY0k++|V1H=P@&o?G6J1>>WlEn?-GcW+zJTUeG-pLmk zl?^$UF)=W#24V<^;$s4-VukUMbuVKCs$`t}n^D?U04ATpn!*a!AqW@Yf~bS>*KjXm z0qO%{2#6Aztj;K_&4a26l@}$P!rQ_SC6dAy%%I6X*_z3Dax;^V>@7wEP39ty4{oui zq^2d7=9Coi0eLSSfW+jfOj{TQCVMffFp5sjXAa{N0jc9CNGvWc&o4@ue2ZB(;ugD8 zetu4BV%{y5lGKWlTS6t}`SEFq$tC$k@u_)bD0Lj#hi?Nwk zVlyvDW?hubx+0l%fko_wgyIaQIh+eL7bq>!T;a4(cZu#r34<#V1{YXFC%W;Sd*jG7-9Fo_P!2}x5jK7wSvV37u!2mq|Q BiYovB delta 424 zcmey#bC{cVIWI340}yQMU7KkYcGaJj(ac#+5P3Xfxh>ttnC)5!~2r5ME~ z?_o8RQUe(RH}e;VO>TZlX-=wLk=A5(HZvO*Zbr=y446cRCD4M5k06;ZSfs%w0ssY( BUzh*@ diff --git a/app/models/__pycache__/user.cpython-312.pyc b/app/models/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fdc64d343eefe5d9892f2c8255ebbea8bfc353c8 GIT binary patch literal 1598 zcmb7DO>7%Q6rTO_U*Z^>q>ZVH(nu9uQJN@lfV8SmC{U1+OUq@YXtnW-lXcg-?(8nE zQxHDnke-^H>?lG>#i2p=p%sVr$Q=$wu^<|$QYB8lAsH^Yv~RrLG&D`6j4yUlM+g6pa0`n~sln62(xS)3_VBlYU56!>8$w{iv6W_|AqAEee`BWT z0h7{<9aNM!jt5S2;&3H_4(RAnv~cPu*62gc_(86rH_H{)tEFBOH+D2LRphV50K`Z(VQt1ps zQD3Mci}<#A(-hL`0#Xu?@$4>m)9mt=8w`fq)KzkkxRVdv3=n(B#!aAZKFs)mtA7z zUCutXpD{|MiK2svoU)y9?(y~nCgmq%k*Gh5k>?Do{==^gJe zX{WAKuO9V&xcTmbv+dN!;7h-=mZ@dev-PR1iyIdo&`0Hm+}6g4<9Kkb|vVJNtqfjl(yM$k~o8^DxVJ~csC-Ur}Cr}kr)f@{A_ oC&0l6%{>j`A4w2|Cn)^{o%uI1D5$HStS!`*)|dW6VD!SD1Mx3cL;wH) literal 0 HcmV?d00001 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 60142fbac202d7a59c2ad6e912f83c4bf4ab1ffc..0bc2badf62c56780452547068029641f26bdaf64 100644 GIT binary patch literal 25819 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 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 0000000000000000000000000000000000000000..698eb894c375e9eb8131268877af3554c0574a1a GIT binary patch literal 22820 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 r>7(g7ENVm2{YZz`j|MbSvw4e$7dxGo&EZNG( literal 0 HcmV?d00001 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 '',