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 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 (
|
||||
current_user_id,
|
||||
@@ -21,10 +22,11 @@ from boker.auth import (
|
||||
verify_reset_token,
|
||||
)
|
||||
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")
|
||||
EMAIL_VERIFICATION_TTL = timedelta(minutes=15)
|
||||
LOGIN_CSRF_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
def db_ready() -> bool:
|
||||
@@ -63,6 +65,23 @@ def _new_verification_code() -> str:
|
||||
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:
|
||||
from boker.db_models import utc_now
|
||||
|
||||
@@ -144,6 +163,7 @@ def register():
|
||||
|
||||
|
||||
@account_bp.route("/login", methods=["GET", "POST"])
|
||||
@csrf.exempt
|
||||
@limiter.limit("20 per minute")
|
||||
def login():
|
||||
if current_user_id():
|
||||
@@ -156,10 +176,23 @@ def login():
|
||||
form = {
|
||||
"email": request.form.get("email", "").strip(),
|
||||
}
|
||||
login_csrf_error = None
|
||||
|
||||
if request.method == "POST":
|
||||
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"])
|
||||
password = request.form.get("password", "")
|
||||
|
||||
@@ -182,7 +215,12 @@ def login():
|
||||
flash("Logged in.", "success")
|
||||
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"])
|
||||
|
||||
@@ -5813,6 +5813,17 @@ select.control { cursor: pointer; }
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
|
||||
<h1 class="auth-split__title">Welcome back.</h1>
|
||||
<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">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="login_csrf_token" value="{{ login_csrf_token }}">
|
||||
<div class="auth-field">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" name="email" autocomplete="email"
|
||||
|
||||
+27
-2
@@ -54,16 +54,41 @@ class ErrorPageTests(unittest.TestCase):
|
||||
client = csrf_app.test_client()
|
||||
|
||||
response = client.post(
|
||||
"/account/login",
|
||||
"/account/register",
|
||||
data={"csrf_token": "stale-token", "email": "owner@example.com", "password": "password123"},
|
||||
)
|
||||
|
||||
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:
|
||||
flashes = flask_session.get("_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__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user