mirror of
https://github.com/wassname/flask-security.git
synced 2026-07-30 12:10:21 +08:00
Merge pull request #78 from eskil/change_password_form
Change password form
This commit is contained in:
@@ -25,3 +25,9 @@ pip-log.txt
|
||||
|
||||
#Mr Developer
|
||||
.mr.developer.cfg
|
||||
|
||||
#Virtualenv
|
||||
env/
|
||||
|
||||
#Editor temporaries
|
||||
*~
|
||||
|
||||
+9
-2
@@ -87,7 +87,13 @@ sends the following signals.
|
||||
|
||||
.. data:: password_reset
|
||||
|
||||
Sent when a user completes a password. It is passed the `user`.
|
||||
Sent when a user completes a password reset. It is passed the
|
||||
`user`.
|
||||
|
||||
.. data:: password_changed
|
||||
|
||||
Sent when a user completes a password change. It is passed the
|
||||
`user`.
|
||||
|
||||
.. data:: reset_password_instructions_sent
|
||||
|
||||
@@ -95,6 +101,7 @@ sends the following signals.
|
||||
with the `user` and `token`, the user being logged in and
|
||||
the (if so configured) the reset token issued.
|
||||
|
||||
All signals are also passed a `app` keyword argument, which is the current application.
|
||||
All signals are also passed a `app` keyword argument, which is the
|
||||
current application.
|
||||
|
||||
.. _Flask documentation on signals: http://flask.pocoo.org/docs/signals/
|
||||
|
||||
@@ -18,6 +18,7 @@ following is a list of view templates:
|
||||
* `security/login_user.html`
|
||||
* `security/register_user.html`
|
||||
* `security/reset_password.html`
|
||||
* `security/change_password.html`
|
||||
* `security/send_confirmation.html`
|
||||
* `security/send_login.html`
|
||||
|
||||
@@ -55,6 +56,7 @@ The following is a list of all the available context processor decorators:
|
||||
* ``login_context_processor``: Login view
|
||||
* ``register_context_processor``: Register view
|
||||
* ``reset_password_context_processor``: Reset password view
|
||||
* ``change_password_context_processor``: Reset password view
|
||||
* ``send_confirmation_context_processor``: Send confirmation view
|
||||
* ``send_login_context_processor``: Send login view
|
||||
|
||||
@@ -94,6 +96,7 @@ The following is a list of all the available form overrides:
|
||||
* ``register_form``: Register form
|
||||
* ``forgot_password_form``: Forgot password form
|
||||
* ``reset_password_form``: Reset password form
|
||||
* ``change_password_form``: Reset password form
|
||||
* ``send_confirmation_form``: Send confirmation form
|
||||
* ``passwordless_login_form``: Passwordless login form
|
||||
|
||||
@@ -112,6 +115,8 @@ The following is a list of email templates:
|
||||
* `security/mail/reset_instructions.html`
|
||||
* `security/mail/reset_instructions.txt`
|
||||
* `security/mail/reset_notice.html`
|
||||
* `security/mail/change_notice.txt`
|
||||
* `security/mail/change_notice.html`
|
||||
* `security/mail/reset_notice.txt`
|
||||
* `security/mail/welcome.html`
|
||||
* `security/mail/welcome.txt`
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
flask.ext.security.changeable
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Flask-Security recoverable module
|
||||
|
||||
:copyright: (c) 2012 by Matt Wright.
|
||||
:author: Eskil Heyn Olsen
|
||||
:license: MIT, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from flask import current_app as app, request
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from .signals import password_changed
|
||||
from .utils import send_mail, encrypt_password, url_for_security, \
|
||||
config_value
|
||||
|
||||
|
||||
# Convenient references
|
||||
_security = LocalProxy(lambda: app.extensions['security'])
|
||||
|
||||
_datastore = LocalProxy(lambda: _security.datastore)
|
||||
|
||||
|
||||
def send_password_changed_notice(user):
|
||||
"""Sends the password changed notice email for the specified user.
|
||||
|
||||
:param user: The user to send the notice to
|
||||
"""
|
||||
send_mail(config_value('EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE'), user.email,
|
||||
'change_notice', user=user)
|
||||
|
||||
|
||||
def change_user_password(user, password):
|
||||
"""Change the specified user's password
|
||||
|
||||
:param user: The user to change_password
|
||||
:param password: The unencrypted new password
|
||||
"""
|
||||
user.password = encrypt_password(password)
|
||||
_datastore.put(user)
|
||||
send_password_changed_notice(user)
|
||||
password_changed.send(user, app=app._get_current_object())
|
||||
+16
-4
@@ -22,8 +22,8 @@ from werkzeug.local import LocalProxy
|
||||
from .utils import config_value as cv, get_config, md5, url_for_security
|
||||
from .views import create_blueprint
|
||||
from .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \
|
||||
ForgotPasswordForm, ResetPasswordForm, SendConfirmationForm, \
|
||||
PasswordlessLoginForm
|
||||
ForgotPasswordForm, ChangePasswordForm, ResetPasswordForm, \
|
||||
SendConfirmationForm, PasswordlessLoginForm
|
||||
|
||||
# Convenient references
|
||||
_security = LocalProxy(lambda: current_app.extensions['security'])
|
||||
@@ -40,6 +40,7 @@ _default_config = {
|
||||
'LOGOUT_URL': '/logout',
|
||||
'REGISTER_URL': '/register',
|
||||
'RESET_URL': '/reset',
|
||||
'CHANGE_URL': '/change',
|
||||
'CONFIRM_URL': '/confirm',
|
||||
'POST_LOGIN_VIEW': '/',
|
||||
'POST_LOGOUT_VIEW': '/',
|
||||
@@ -47,12 +48,14 @@ _default_config = {
|
||||
'POST_REGISTER_VIEW': None,
|
||||
'POST_CONFIRM_VIEW': None,
|
||||
'POST_RESET_VIEW': None,
|
||||
'POST_CHANGE_VIEW': None,
|
||||
'UNAUTHORIZED_VIEW': None,
|
||||
'CONFIRMABLE': False,
|
||||
'REGISTERABLE': False,
|
||||
'RECOVERABLE': False,
|
||||
'TRACKABLE': False,
|
||||
'PASSWORDLESS': False,
|
||||
'CHANGEABLE': False,
|
||||
'LOGIN_WITHIN': '1 days',
|
||||
'CONFIRM_EMAIL_WITHIN': '5 days',
|
||||
'RESET_PASSWORD_WITHIN': '5 days',
|
||||
@@ -63,12 +66,14 @@ _default_config = {
|
||||
'CONFIRM_SALT': 'confirm-salt',
|
||||
'RESET_SALT': 'reset-salt',
|
||||
'LOGIN_SALT': 'login-salt',
|
||||
'CHANGE_SALT': 'change-salt',
|
||||
'REMEMBER_SALT': 'remember-salt',
|
||||
'DEFAULT_HTTP_AUTH_REALM': 'Login Required',
|
||||
'EMAIL_SUBJECT_REGISTER': 'Welcome',
|
||||
'EMAIL_SUBJECT_CONFIRM': 'Please confirm your email',
|
||||
'EMAIL_SUBJECT_PASSWORDLESS': 'Login instructions',
|
||||
'EMAIL_SUBJECT_PASSWORD_NOTICE': 'Your password has been reset',
|
||||
'EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE': 'Your password has been changed',
|
||||
'EMAIL_SUBJECT_PASSWORD_RESET': 'Password reset instructions'
|
||||
}
|
||||
|
||||
@@ -97,6 +102,8 @@ _default_messages = {
|
||||
'INVALID_PASSWORD': ('Invalid password', 'error'),
|
||||
'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in.', 'success'),
|
||||
'PASSWORD_RESET': ('You successfully reset your password and you have been logged in automatically.', 'success'),
|
||||
'PASSWORD_CHANGE': ('You successfully changed your password.', 'success'),
|
||||
'INVALID_PASSWORD': ('Invalid password', 'error'),
|
||||
'LOGIN': ('Please log in to access this page.', 'info'),
|
||||
'REFRESH': ('Please reauthenticate to access this page.', 'info'),
|
||||
}
|
||||
@@ -118,6 +125,7 @@ _default_forms = {
|
||||
'register_form': RegisterForm,
|
||||
'forgot_password_form': ForgotPasswordForm,
|
||||
'reset_password_form': ResetPasswordForm,
|
||||
'change_password_form': ChangePasswordForm,
|
||||
'send_confirmation_form': SendConfirmationForm,
|
||||
'passwordless_login_form': PasswordlessLoginForm,
|
||||
}
|
||||
@@ -290,6 +298,9 @@ class _SecurityState(object):
|
||||
def reset_password_context_processor(self, fn):
|
||||
self._add_ctx_processor('reset_password', fn)
|
||||
|
||||
def change_password_context_processor(self, fn):
|
||||
self._add_ctx_processor('change_password', fn)
|
||||
|
||||
def send_confirmation_context_processor(self, fn):
|
||||
self._add_ctx_processor('send_confirmation', fn)
|
||||
|
||||
@@ -319,8 +330,8 @@ class Security(object):
|
||||
def init_app(self, app, datastore=None, register_blueprint=True,
|
||||
login_form=None, confirm_register_form=None,
|
||||
register_form=None, forgot_password_form=None,
|
||||
reset_password_form=None, send_confirmation_form=None,
|
||||
passwordless_login_form=None):
|
||||
reset_password_form=None, change_password_form=None,
|
||||
send_confirmation_form=None, passwordless_login_form=None):
|
||||
"""Initializes the Flask-Security extension for the specified
|
||||
application and datastore implentation.
|
||||
|
||||
@@ -344,6 +355,7 @@ class Security(object):
|
||||
register_form=register_form,
|
||||
forgot_password_form=forgot_password_form,
|
||||
reset_password_form=reset_password_form,
|
||||
change_password_form=change_password_form,
|
||||
send_confirmation_form=send_confirmation_form,
|
||||
passwordless_login_form=passwordless_login_form)
|
||||
|
||||
|
||||
+29
-2
@@ -15,10 +15,11 @@ from flask import request, current_app
|
||||
from flask.ext.wtf import Form as BaseForm, TextField, PasswordField, \
|
||||
SubmitField, HiddenField, Required, BooleanField, EqualTo, Email, \
|
||||
ValidationError, Length, Field
|
||||
from flask.ext.login import current_user
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from .confirmable import requires_confirmation
|
||||
from .utils import verify_and_update_password, get_message
|
||||
from .utils import verify_password, verify_and_update_password, get_message
|
||||
|
||||
# Convenient reference
|
||||
_datastore = LocalProxy(lambda: current_app.extensions['security'].datastore)
|
||||
@@ -95,7 +96,8 @@ class RegisterFormMixin():
|
||||
|
||||
def to_dict(form):
|
||||
def is_field_and_user_attr(member):
|
||||
return isinstance(member, Field) and hasattr(_datastore.user_model, member.name)
|
||||
return isinstance(member, Field) and \
|
||||
hasattr(_datastore.user_model, member.name)
|
||||
|
||||
fields = inspect.getmembers(form, is_field_and_user_attr)
|
||||
return dict((key, value.data) for key, value in fields)
|
||||
@@ -196,3 +198,28 @@ class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin):
|
||||
"""The default reset password form"""
|
||||
|
||||
submit = SubmitField("Reset Password")
|
||||
|
||||
|
||||
class ChangePasswordForm(Form, PasswordFormMixin):
|
||||
"""The default change password form"""
|
||||
|
||||
new_password = PasswordField("New Password",
|
||||
validators=[password_required,
|
||||
Length(min=6, max=128)])
|
||||
|
||||
new_password_confirm = PasswordField("Retype Password",
|
||||
validators=[EqualTo('new_password', message="Passwords do not match")])
|
||||
|
||||
submit = SubmitField("Change Password")
|
||||
|
||||
def validate(self):
|
||||
if not super(ChangePasswordForm, self).validate():
|
||||
return False
|
||||
|
||||
if self.password.data.strip() == '':
|
||||
self.password.errors.append('Password not provided')
|
||||
return False
|
||||
if not verify_and_update_password(self.password.data, current_user):
|
||||
self.password.errors.append(get_message('INVALID_PASSWORD')[0])
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -24,4 +24,6 @@ login_instructions_sent = signals.signal("login-instructions-sent")
|
||||
|
||||
password_reset = signals.signal("password-reset")
|
||||
|
||||
password_changed = signals.signal("password-changed")
|
||||
|
||||
reset_password_instructions_sent = signals.signal("password-reset-instructions-sent")
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
<li><a href="{{ url_for_security('send_confirmation') }}">Confirm account</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{% from "security/_macros.html" import render_field_with_errors, render_field %}
|
||||
{% include "security/_messages.html" %}
|
||||
<h1>Change password</h1>
|
||||
<form action="{{ url_for_security('change_password') }}" method="POST" name="change_password_form">
|
||||
{{ change_password_form.hidden_tag() }}
|
||||
{{ render_field_with_errors(change_password_form.password) }}
|
||||
{{ render_field_with_errors(change_password_form.new_password) }}
|
||||
{{ render_field_with_errors(change_password_form.new_password_confirm) }}
|
||||
{{ render_field(change_password_form.submit) }}
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<p>Your password has been changed.</p>
|
||||
{% if security.recoverable %}
|
||||
<p>If you did not change your password, <a href="{{ url_for_security('forgot_password', _external=True) }}">click here to reset it</a>.</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,5 @@
|
||||
Your password has been changed
|
||||
{% if security.recoverable %}
|
||||
If you did not change your password, click the link below to reset it.
|
||||
{{ url_for_security('forgot_password', _external=True) }}
|
||||
{% endif %}
|
||||
@@ -27,7 +27,7 @@ from werkzeug.local import LocalProxy
|
||||
|
||||
from .signals import user_registered, user_confirmed, \
|
||||
confirm_instructions_sent, login_instructions_sent, \
|
||||
password_reset, reset_password_instructions_sent
|
||||
password_reset, password_changed, reset_password_instructions_sent
|
||||
|
||||
# Convenient references
|
||||
_security = LocalProxy(lambda: current_app.extensions['security'])
|
||||
@@ -373,6 +373,7 @@ def capture_signals():
|
||||
"""Factory method that creates a `CaptureSignals` with all the flask_security signals."""
|
||||
return CaptureSignals([user_registered, user_confirmed,
|
||||
confirm_instructions_sent, login_instructions_sent,
|
||||
password_reset, reset_password_instructions_sent])
|
||||
password_reset, password_changed,
|
||||
reset_password_instructions_sent])
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
from flask import current_app, redirect, request, render_template, jsonify, \
|
||||
after_this_request, Blueprint
|
||||
from flask_login import current_user
|
||||
from werkzeug.datastructures import MultiDict
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
@@ -21,6 +22,7 @@ from .passwordless import send_login_instructions, \
|
||||
login_token_status
|
||||
from .recoverable import reset_password_token_status, \
|
||||
send_reset_password_instructions, update_password
|
||||
from .changeable import change_user_password
|
||||
from .registerable import register_user
|
||||
from .utils import get_url, get_post_login_redirect, do_flash, \
|
||||
get_message, login_user, logout_user, url_for_security as url_for
|
||||
@@ -278,6 +280,33 @@ def reset_password(token):
|
||||
**_ctx('reset_password'))
|
||||
|
||||
|
||||
@login_required
|
||||
def change_password():
|
||||
"""View function which handles a change password request."""
|
||||
|
||||
form_class = _security.change_password_form
|
||||
|
||||
if request.json:
|
||||
form = form_class(MultiDict(request.json))
|
||||
else:
|
||||
form = form_class()
|
||||
|
||||
if form.validate_on_submit():
|
||||
after_this_request(_commit)
|
||||
change_user_password(current_user, form.new_password.data)
|
||||
if request.json is None:
|
||||
do_flash(*get_message('PASSWORD_CHANGE'))
|
||||
return redirect(get_url(_security.post_change_view) or
|
||||
get_url(_security.post_login_view))
|
||||
|
||||
if request.json:
|
||||
return _render_json(form)
|
||||
|
||||
return render_template('security/change_password.html',
|
||||
change_password_form=form,
|
||||
**_ctx('change_password'))
|
||||
|
||||
|
||||
def create_blueprint(state, import_name):
|
||||
"""Creates the security extension blueprint"""
|
||||
|
||||
@@ -311,6 +340,11 @@ def create_blueprint(state, import_name):
|
||||
methods=['GET', 'POST'],
|
||||
endpoint='reset_password')(reset_password)
|
||||
|
||||
if state.changeable:
|
||||
bp.route(state.change_url,
|
||||
methods=['GET', 'POST'],
|
||||
endpoint='change_password')(change_password)
|
||||
|
||||
if state.confirmable:
|
||||
bp.route(state.confirm_url,
|
||||
methods=['GET', 'POST'],
|
||||
|
||||
@@ -317,6 +317,95 @@ class ExpiredResetPasswordTest(SecurityTest):
|
||||
self.assertIn('You did not reset your password within', r.data)
|
||||
|
||||
|
||||
class ChangePasswordTest(SecurityTest):
|
||||
|
||||
AUTH_CONFIG = {
|
||||
'SECURITY_RECOVERABLE': True,
|
||||
'SECURITY_CHANGEABLE': True,
|
||||
}
|
||||
|
||||
def test_change_password(self):
|
||||
self.authenticate()
|
||||
r = self.client.get('/change', follow_redirects=True)
|
||||
self.assertIn('Change password', r.data)
|
||||
|
||||
def test_change_password_invalid(self):
|
||||
self.authenticate()
|
||||
r = self.client.post('/change', data={
|
||||
'password': 'notpassword',
|
||||
'new_password': 'newpassword',
|
||||
'new_password_confirm': 'newpassword'
|
||||
}, follow_redirects=True)
|
||||
self.assertNotIn('You successfully changed your password', r.data)
|
||||
self.assertIn('Invalid password', r.data)
|
||||
|
||||
def test_change_password_mismatch(self):
|
||||
self.authenticate()
|
||||
r = self.client.post('/change', data={
|
||||
'password': 'password',
|
||||
'new_password': 'newpassword',
|
||||
'new_password_confirm': 'notnewpassword'
|
||||
}, follow_redirects=True)
|
||||
self.assertNotIn('You successfully changed your password', r.data)
|
||||
self.assertIn('Passwords do not match', r.data)
|
||||
|
||||
def test_change_password_bad_password(self):
|
||||
self.authenticate()
|
||||
r = self.client.post('/change', data={
|
||||
'password': 'password',
|
||||
'new_password': 'a',
|
||||
'new_password_confirm': 'a'
|
||||
}, follow_redirects=True)
|
||||
self.assertNotIn('You successfully changed your password', r.data)
|
||||
self.assertIn('Field must be between', r.data)
|
||||
|
||||
def test_change_password_success(self):
|
||||
self.authenticate()
|
||||
with self.app.extensions['mail'].record_messages() as outbox:
|
||||
r = self.client.post('/change', data={
|
||||
'password': 'password',
|
||||
'new_password': 'newpassword',
|
||||
'new_password_confirm': 'newpassword'
|
||||
}, follow_redirects=True)
|
||||
|
||||
self.assertIn('You successfully changed your password', r.data)
|
||||
self.assertIn('Home Page', r.data)
|
||||
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertIn("Your password has been changed", outbox[0].html)
|
||||
self.assertIn("/reset", outbox[0].html)
|
||||
|
||||
|
||||
class ChangePasswordPostViewTest(SecurityTest):
|
||||
|
||||
AUTH_CONFIG = {
|
||||
'SECURITY_CHANGEABLE': True,
|
||||
'SECURITY_POST_CHANGE_VIEW': '/profile',
|
||||
}
|
||||
|
||||
def test_change_password_success(self):
|
||||
self.authenticate()
|
||||
r = self.client.post('/change', data={
|
||||
'password': 'password',
|
||||
'new_password': 'newpassword',
|
||||
'new_password_confirm': 'newpassword'
|
||||
}, follow_redirects=True)
|
||||
|
||||
self.assertIn('Profile Page', r.data)
|
||||
|
||||
|
||||
class ChangePasswordDisabledTest(SecurityTest):
|
||||
|
||||
AUTH_CONFIG = {
|
||||
'SECURITY_CHANGEABLE': False,
|
||||
}
|
||||
|
||||
def test_change_password_endpoint_is_404(self):
|
||||
self.authenticate()
|
||||
r = self.client.get('/change', follow_redirects=True)
|
||||
self.assertEqual(404, r.status_code)
|
||||
|
||||
|
||||
class TrackableTests(SecurityTest):
|
||||
|
||||
AUTH_CONFIG = {
|
||||
|
||||
+52
-1
@@ -3,7 +3,8 @@ from __future__ import with_statement
|
||||
from flask_security.utils import (capture_registrations, capture_reset_password_requests, capture_signals)
|
||||
from flask_security.signals import (user_registered, user_confirmed,
|
||||
confirm_instructions_sent, login_instructions_sent,
|
||||
password_reset, reset_password_instructions_sent)
|
||||
password_reset, password_changed,
|
||||
reset_password_instructions_sent)
|
||||
from tests import SecurityTest
|
||||
|
||||
|
||||
@@ -151,6 +152,56 @@ class RecoverableSignalsTests(SecurityTest):
|
||||
self.assertEqual(mocks.signals_sent(), set())
|
||||
|
||||
|
||||
class ChangeableSignalsTests(SecurityTest):
|
||||
|
||||
AUTH_CONFIG = {
|
||||
'SECURITY_CHANGEABLE': True,
|
||||
}
|
||||
|
||||
def test_change_password(self):
|
||||
self.authenticate('joe@lp.com')
|
||||
with capture_signals() as mocks:
|
||||
with self.client as client:
|
||||
client.post('/change',
|
||||
data=dict(password='password',
|
||||
new_password='newpassword',
|
||||
new_password_confirm='newpassword'))
|
||||
self.assertEqual(mocks.signals_sent(), set([password_changed]))
|
||||
user = self.app.security.datastore.find_user(email='joe@lp.com')
|
||||
calls = mocks[password_changed]
|
||||
self.assertEqual(len(calls), 1)
|
||||
args, kwargs = calls[0]
|
||||
self.assertTrue(compare_user(args[0], user))
|
||||
self.assertEqual(kwargs['app'], self.app)
|
||||
|
||||
def test_change_password_invalid_password(self):
|
||||
with capture_signals() as mocks:
|
||||
self.client.post('/change',
|
||||
data=dict(password='notpassword',
|
||||
new_password='newpassword',
|
||||
new_password_confirm='newpassword'),
|
||||
follow_redirects=True)
|
||||
self.assertEqual(mocks.signals_sent(), set())
|
||||
|
||||
def test_change_password_bad_password(self):
|
||||
with capture_signals() as mocks:
|
||||
self.client.post('/change',
|
||||
data=dict(password='notpassword',
|
||||
new_password='a',
|
||||
new_password_confirm='a'),
|
||||
follow_redirects=True)
|
||||
self.assertEqual(mocks.signals_sent(), set())
|
||||
|
||||
def test_change_password_mismatch_password(self):
|
||||
with capture_signals() as mocks:
|
||||
self.client.post('/change',
|
||||
data=dict(password='password',
|
||||
new_password='newpassword',
|
||||
new_password_confirm='notnewpassword'),
|
||||
follow_redirects=True)
|
||||
self.assertEqual(mocks.signals_sent(), set())
|
||||
|
||||
|
||||
class PasswordlessTests(SecurityTest):
|
||||
|
||||
AUTH_CONFIG = {
|
||||
|
||||
Reference in New Issue
Block a user