resolve mobile login
This commit is contained in:
4 files changed
+83
-6
No files matched your search
+41
-3
@@ -5,7 +5,8 @@ import secrets
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, session as flask_session, url_for
|
from flask import Blueprint, current_app, flash, redirect, render_template, request, session as flask_session, url_for
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
|
||||||
from boker.auth import (
|
from boker.auth import (
|
||||||
current_user_id,
|
current_user_id,
|
||||||
@@ -21,10 +22,11 @@ from boker.auth import (
|
|||||||
verify_reset_token,
|
verify_reset_token,
|
||||||
)
|
)
|
||||||
from boker.db import database_extensions_available, db
|
from boker.db import database_extensions_available, db
|
||||||
from boker.extensions import limiter
|
from boker.extensions import csrf, limiter
|
||||||
|
|
||||||
account_bp = Blueprint("account", __name__, url_prefix="/account")
|
account_bp = Blueprint("account", __name__, url_prefix="/account")
|
||||||
EMAIL_VERIFICATION_TTL = timedelta(minutes=15)
|
EMAIL_VERIFICATION_TTL = timedelta(minutes=15)
|
||||||
|
LOGIN_CSRF_TTL_SECONDS = 3600
|
||||||
|
|
||||||
|
|
||||||
def db_ready() -> bool:
|
def db_ready() -> bool:
|
||||||
@@ -63,6 +65,23 @@ def _new_verification_code() -> str:
|
|||||||
return f"{secrets.randbelow(1000000):06d}"
|
return f"{secrets.randbelow(1000000):06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _login_csrf_serializer() -> URLSafeTimedSerializer:
|
||||||
|
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_login_csrf_token() -> str:
|
||||||
|
return _login_csrf_serializer().dumps("login", salt="login-form")
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_login_csrf_token(token: str) -> bool:
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return _login_csrf_serializer().loads(token, salt="login-form", max_age=LOGIN_CSRF_TTL_SECONDS) == "login"
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _issue_verification_code(user) -> str:
|
def _issue_verification_code(user) -> str:
|
||||||
from boker.db_models import utc_now
|
from boker.db_models import utc_now
|
||||||
|
|
||||||
@@ -144,6 +163,7 @@ def register():
|
|||||||
|
|
||||||
|
|
||||||
@account_bp.route("/login", methods=["GET", "POST"])
|
@account_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
@csrf.exempt
|
||||||
@limiter.limit("20 per minute")
|
@limiter.limit("20 per minute")
|
||||||
def login():
|
def login():
|
||||||
if current_user_id():
|
if current_user_id():
|
||||||
@@ -156,10 +176,23 @@ def login():
|
|||||||
form = {
|
form = {
|
||||||
"email": request.form.get("email", "").strip(),
|
"email": request.form.get("email", "").strip(),
|
||||||
}
|
}
|
||||||
|
login_csrf_error = None
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
from boker.league_repositories import find_user_by_email
|
from boker.league_repositories import find_user_by_email
|
||||||
|
|
||||||
|
if not _valid_login_csrf_token(request.form.get("login_csrf_token", "")):
|
||||||
|
login_csrf_error = "Your sign-in form expired. Please try again."
|
||||||
|
return (
|
||||||
|
render_template(
|
||||||
|
"account_login.html",
|
||||||
|
form=form,
|
||||||
|
login_csrf_token=_issue_login_csrf_token(),
|
||||||
|
login_csrf_error=login_csrf_error,
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
user = find_user_by_email(form["email"])
|
user = find_user_by_email(form["email"])
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
|
|
||||||
@@ -182,7 +215,12 @@ def login():
|
|||||||
flash("Logged in.", "success")
|
flash("Logged in.", "success")
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
|
|
||||||
return render_template("account_login.html", form=form)
|
return render_template(
|
||||||
|
"account_login.html",
|
||||||
|
form=form,
|
||||||
|
login_csrf_token=_issue_login_csrf_token(),
|
||||||
|
login_csrf_error=login_csrf_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@account_bp.route("/verify-email", methods=["GET", "POST"])
|
@account_bp.route("/verify-email", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -5813,6 +5813,17 @@ select.control { cursor: pointer; }
|
|||||||
line-height: 1.58;
|
line-height: 1.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-inline-error {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
border: 1px solid var(--neg-tint-bd);
|
||||||
|
background: var(--neg-tint);
|
||||||
|
color: var(--neg);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-field {
|
.auth-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
|
|
||||||
<h1 class="auth-split__title">Welcome back.</h1>
|
<h1 class="auth-split__title">Welcome back.</h1>
|
||||||
<p class="auth-split__sub">Sign in to manage your leagues, sessions, and ledger.</p>
|
<p class="auth-split__sub">Sign in to manage your leagues, sessions, and ledger.</p>
|
||||||
|
{% if login_csrf_error %}
|
||||||
|
<div class="auth-inline-error" role="alert">{{ login_csrf_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="login_csrf_token" value="{{ login_csrf_token }}">
|
||||||
<div class="auth-field">
|
<div class="auth-field">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input id="email" type="email" name="email" autocomplete="email"
|
<input id="email" type="email" name="email" autocomplete="email"
|
||||||
|
|||||||
+27
-2
@@ -54,16 +54,41 @@ class ErrorPageTests(unittest.TestCase):
|
|||||||
client = csrf_app.test_client()
|
client = csrf_app.test_client()
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/account/login",
|
"/account/register",
|
||||||
data={"csrf_token": "stale-token", "email": "owner@example.com", "password": "password123"},
|
data={"csrf_token": "stale-token", "email": "owner@example.com", "password": "password123"},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], "/account/login")
|
self.assertEqual(response.headers["Location"], "/account/register")
|
||||||
with client.session_transaction() as flask_session:
|
with client.session_transaction() as flask_session:
|
||||||
flashes = flask_session.get("_flashes", [])
|
flashes = flask_session.get("_flashes", [])
|
||||||
self.assertIn(("error", "Your form expired. Please try again."), flashes)
|
self.assertIn(("error", "Your form expired. Please try again."), flashes)
|
||||||
|
|
||||||
|
def test_login_uses_stateless_csrf_error(self):
|
||||||
|
csrf_app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"PROPAGATE_EXCEPTIONS": False,
|
||||||
|
"SQLALCHEMY_DATABASE_URI": self.app.config["SQLALCHEMY_DATABASE_URI"],
|
||||||
|
"WTF_CSRF_ENABLED": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client = csrf_app.test_client()
|
||||||
|
|
||||||
|
login_page = client.get("/account/login")
|
||||||
|
self.assertEqual(login_page.status_code, 200)
|
||||||
|
self.assertIsNone(login_page.headers.get("Set-Cookie"))
|
||||||
|
self.assertIn(b'name="login_csrf_token"', login_page.data)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/account/login",
|
||||||
|
data={"email": "owner@example.com", "password": "password123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn(b"Your sign-in form expired. Please try again.", response.data)
|
||||||
|
self.assertIn(b'name="login_csrf_token"', response.data)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user