Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@ Here you can see the full list of changes between each Flask-Security release.
Version 5.6.0
-------------

Released TBD
Released January xx, 2025

Features & Improvements
+++++++++++++++++++++++
- (:issue:`1038`) Add support for 'secret_key' rotation (jamesejr)
- (:issue:`980`) Add support for username recovery in simple login flows (jamesejr)
- (:issue:`1055`) Add support for changing username
- (:pr:`1048`) Add support for Python 3.13
- (:issue:`1043`) Unify Register forms (and split out re-type password option)
- (:pr:`1052`) Remove deprecated TWO_FACTOR configuration variables
- (:issue:`1043`) Unify Register forms (and split out re-type password option) Please read :ref:`register_form_migration`.

Fixes
+++++
- (:pr:`zz`) Fix duplicate HTML ids in templates.
- (:pr:`1062`) Fix duplicate HTML ids in templates.
- (:issue:`xx`) Ensure templates pass W3C validation (see below)

Docs and Chores
+++++++++++++++
- (:pr:`1052`) Remove deprecated TWO_FACTOR configuration variables

Notes
+++++
Expand All @@ -40,6 +44,15 @@ have been removed (they have been deprecated for a while). Use the equivalent
:py:data:`SECURITY_TOTP_SECRETS`, :py:data:`SECURITY_TOTP_ISSUER`, :py:data:`SECURITY_SMS_SERVICE` and
:py:data:`SECURITY_SMS_SERVICE_CONFIG`.

Backwards Compatibility Concerns
+++++++++++++++++++++++++++++++++
Fixing all the templates to pass W3C validation could introduce some incompatibilities:

- All templates now have a default <title> - before the <title> element was empty.
- The HTML id of the rescue form submit button was changed to 'rescue'
- The HTML id of the webauthn delete form name field was changed to 'delete-name'
- Some template headings were changed to improve consistency

Version 5.5.2
-------------

Expand Down
2 changes: 1 addition & 1 deletion flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1687,7 +1687,7 @@ def init_app(
build_register_form(app=app, fcls=fcls)
fcls = self.forms["change_username_form"].cls
if fcls and issubclass(fcls, ChangeUsernameForm):
fcls.username = build_username_field(app=app, autocomplete="new-username")
fcls.username = build_username_field(app=app)

# initialize two-factor plugins. Note that each implementation likely
# has its own feature flag which will control whether it is active or not.
Expand Down
8 changes: 4 additions & 4 deletions flask_security/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,14 +369,14 @@ class CodeFormMixin:
get_form_field_label("code"),
render_kw={
"autocomplete": "one-time-code",
"inputtype": "numeric",
"type": "text",
"pattern": "[0-9]*",
},
validators=[RequiredLocalize()],
)


def build_username_field(app, autocomplete="username"):
def build_username_field(app):
if cv("USERNAME_REQUIRED", app=app):
validators = [
RequiredLocalize(message="USERNAME_NOT_PROVIDED"),
Expand All @@ -387,7 +387,7 @@ def build_username_field(app, autocomplete="username"):
validators = [username_validator, unique_username]
return StringField(
get_form_field_label("username"),
render_kw={"autocomplete": autocomplete},
render_kw={"autocomplete": "username"},
validators=validators,
)

Expand Down Expand Up @@ -1017,7 +1017,7 @@ class TwoFactorRescueForm(Form):
("help", get_form_field_xlate(_("Contact Administrator"))),
],
)
submit = SubmitField(get_form_field_label("submit"))
submit = SubmitField(get_form_field_label("submit"), id="rescue")


class UsernameRecoveryForm(Form, UserEmailFormMixin):
Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<title>{% block title %}{{ title|default }}{% endblock title %}</title>

{%- block metas %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{%- endblock metas %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/change_email.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain('Change email')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/change_password.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain('Change password')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/change_username.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain('Change Username')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/forgot_password.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain('Send password reset instructions')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/login_user.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain('Login')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors, prop_next %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/mf_recovery.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain("Enter Recovery Code")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/mf_recovery_codes.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain('Recovery Codes')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors %}

Expand Down
3 changes: 2 additions & 1 deletion flask_security/templates/security/recover_username.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{% set title = title|default(_fsdomain('Username Recovery')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}

{% block content %}
{% include "security/_messages.html" %}
<h1>{{ _fsdomain('Username recovery') }}</h1>
<h1>{{ _fsdomain('Username Recovery') }}</h1>
<form action="{{ url_for_security('recover_username') }}" method="post" name="username_recovery_form">
{{ username_recovery_form.hidden_tag() }}
{{ render_form_errors(username_recovery_form) }}
Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/register_user.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain('Register')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_form_errors, render_field_errors %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/reset_password.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain("Reset password")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/send_confirmation.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain("Resend confirmation instructions")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/send_login.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain("Login")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}

Expand Down
3 changes: 2 additions & 1 deletion flask_security/templates/security/two_factor_select.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{% set title = title|default(_fsdomain("Select Two-Factor Method")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import prop_next, render_field_with_errors, render_field %}

{% block content %}
{% include "security/_messages.html" %}
<h1>{{ _fsdomain('Select Two Factor Method') }}</h1>
<h1>{{ _fsdomain('Select Two-Factor Method') }}</h1>
<form action="{{ url_for_security('tf_select') }}{{ prop_next() }}" method="post" name="tf_select">
{{ two_factor_select_form.hidden_tag() }}
{{ render_field_with_errors(two_factor_select_form.which) }}
Expand Down
2 changes: 1 addition & 1 deletion flask_security/templates/security/two_factor_setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
authr_username: same username as in qrcode
authr_issuer: same issuer as in qrcode
#}

{% set title = title|default(_fsdomain("Two-Factor Setup")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_no_label, render_field_errors, render_form_errors %}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain("Two-Factor Authentication")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, prop_next %}

Expand Down
2 changes: 1 addition & 1 deletion flask_security/templates/security/us_setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
authr_username: same username as in qrcode
authr_issuer: same issuer as in qrcode
#}

{% set title = title|default(_fsdomain('Setup Unified Sign In')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %}

Expand Down
1 change: 1 addition & 0 deletions flask_security/templates/security/us_signin.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% set title = title|default(_fsdomain('Sign In')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors, prop_next %}

Expand Down
3 changes: 2 additions & 1 deletion flask_security/templates/security/us_verify.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{% set title = title|default(_fsdomain('Reauthenticate')) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, prop_next %}

{% block content %}
{% include "security/_messages.html" %}
<h1>{{ _fsdomain("Please Reauthenticate") }}</h1>
<h1>{{ _fsdomain("Reauthenticate") }}</h1>
<form action="{{ url_for_security('us_verify') }}{{ prop_next() }}" method="post" name="us_verify_form">
{{ us_verify_form.hidden_tag() }}
{{ render_field_with_errors(us_verify_form.passcode) }}
Expand Down
3 changes: 2 additions & 1 deletion flask_security/templates/security/verify.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{% set title = title|default(_fsdomain("Reauthenticate")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, prop_next %}

{% block content %}
{% include "security/_messages.html" %}
<h1>{{ _fsdomain("Please Reauthenticate") }}</h1>
<h1>{{ _fsdomain("Reauthenticate") }}</h1>
<form action="{{ url_for_security('verify') }}{{ prop_next() }}" method="post" name="verify_form">
{{ verify_form.hidden_tag() }}
{{ render_field_with_errors(verify_form.password) }}
Expand Down
4 changes: 2 additions & 2 deletions flask_security/templates/security/wan_register.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{#
This template receives the following pieces of context in addition to the form:
#}

{% set title = title|default(_fsdomain("Setup New WebAuthn Security Key")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors %}

{% block head_scripts %}
{{ super() }}
<script src="{{ url_for('.static', filename='js/webauthn.js') }}" xmlns="http://www.w3.org/1999/html"></script>
<script src="{{ url_for('.static', filename='js/webauthn.js') }}"></script>
<script src="{{ url_for('.static', filename='js/base64.js') }}"></script>
{% endblock head_scripts %}

Expand Down
4 changes: 2 additions & 2 deletions flask_security/templates/security/wan_signin.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{#
This template receives the following pieces of context in addition to the form:
#}

{% set title = title|default(_fsdomain("WebAuthn Security Key")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors, prop_next %}

{% block head_scripts %}
{{ super() }}
<script src="{{ url_for('.static', filename='js/webauthn.js') }}" xmlns="http://www.w3.org/1999/html"></script>
<script src="{{ url_for('.static', filename='js/webauthn.js') }}"></script>
<script src="{{ url_for('.static', filename='js/base64.js') }}"></script>
{% endblock head_scripts %}

Expand Down
6 changes: 3 additions & 3 deletions flask_security/templates/security/wan_verify.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@
skip_login_menu - True
Any other context provided by the "wan_verify" context processer.
#}

{% set title = title|default(_fsdomain("Re-Authenticate Using Your WebAuthn Security Key")) %}
{% extends "security/base.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field, prop_next %}

{% block head_scripts %}
{{ super() }}
<script src="{{ url_for('.static', filename='js/webauthn.js') }}" xmlns="http://www.w3.org/1999/html"></script>
<script src="{{ url_for('.static', filename='js/webauthn.js') }}"></script>
<script src="{{ url_for('.static', filename='js/base64.js') }}"></script>
{% endblock head_scripts %}

{% block content %}
{% include "security/_messages.html" %}
<h1>{{ _fsdomain("Please Re-Authenticate Using Your WebAuthn Security Key") }}</h1>
<h1>{{ _fsdomain("Re-Authenticate Using Your WebAuthn Security Key") }}</h1>
{% if not credential_options %}
<form action="{{ url_for_security('wan_verify') }}{{ prop_next() }}" method="post" name="wan_verify_form">
{{ wan_verify_form.hidden_tag() }}
Expand Down
3 changes: 3 additions & 0 deletions flask_security/webauthn.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,12 @@ def validate(self, **kwargs: t.Any) -> bool:


class WebAuthnDeleteForm(Form):
# Change id of name since this shows up on register form that ALSO has a name
# element.
name = StringField(
get_form_field_xlate(_("Nickname")),
validators=[RequiredLocalize(message="WEBAUTHN_NAME_REQUIRED")],
id="delete-name",
)
submit = SubmitField(label=get_form_field_label("delete"))

Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Test fixtures and what not

:copyright: (c) 2017 by CERN.
:copyright: (c) 2019-2024 by J. Christopher Wagner (jwag).
:copyright: (c) 2019-2025 by J. Christopher Wagner (jwag).
:license: MIT, see LICENSE for more details.
"""

Expand Down Expand Up @@ -182,7 +182,7 @@ def app(request):
if request.config.option.setting:
for s in request.config.option.setting:
key, value = s.split("=")
app.config["SECURITY_" + key.upper()] = convert_bool_option(value)
app.config[key.upper()] = convert_bool_option(value)

app.mail = Mail(app) # type: ignore

Expand Down
8 changes: 4 additions & 4 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,7 @@ def test_verify_fresh(app, client, get_message):

with capture_flashes() as flashes:
response = client.get("/fresh", follow_redirects=True)
assert b"Please Reauthenticate" in response.data
assert b"Reauthenticate" in response.data
assert flashes[0]["category"] == "error"
assert flashes[0]["message"].encode("utf-8") == get_message(
"REAUTHENTICATION_REQUIRED"
Expand All @@ -1161,12 +1161,12 @@ def test_verify_fresh(app, client, get_message):

reset_fresh(client, app.config["SECURITY_FRESHNESS"])
response = client.get(verify_url)
assert b"Please Reauthenticate" in response.data
assert b"Reauthenticate" in response.data

response = client.post(
verify_url, data=dict(password="not my password"), follow_redirects=False
)
assert b"Please Reauthenticate" in response.data
assert b"Reauthenticate" in response.data

response = client.post(
verify_url, data=dict(password="password"), follow_redirects=False
Expand All @@ -1189,7 +1189,7 @@ def test_verify_fresh_json(app, client, get_message):
assert response.json["response"]["reauth_required"]

response = client.get("/verify")
assert b"Please Reauthenticate" in response.data
assert b"Reauthenticate" in response.data

response = client.post(
"/verify", json=dict(password="not my password"), headers=headers
Expand Down
2 changes: 1 addition & 1 deletion tests/test_recoverable.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,7 @@ def on_email_sent(app, **kwargs):

# Test the username recovery view
response = clients.get("/recover-username")
assert b"<h1>Username recovery</h1>" in response.data
assert b"<h1>Username Recovery</h1>" in response.data

response = clients.post(
"/recover-username", data=dict(email="joe@lp.com"), follow_redirects=True
Expand Down
Loading
Loading