diff --git a/docs/customizing.rst b/docs/customizing.rst index d10a522..3aff81c 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -59,6 +59,37 @@ The following is a list of all the available context processor decorators: * ``send_login_context_processor``: Send login view +Forms +----- + +All forms can be overridden in much the same way as context +processors. For each form used, you can specify a function that +returns the form class. This allows you to override/extend forms. For +example, to add extra fields to the register form:: + + security = Security(app, user_datastore) + + from flask_security.forms import RegisterForm + + class ExtendedRegisterForm(RegisterForm): + first_name = TextField('First Name', [Required()]) + last_name = TextField('Last Name', [Required()]) + + @security.register_form + def security_register_form(): + return ExtendedRegisterForm + +The following is a list of all the available form override decorators: + +* ``login_form``: Login form +* ``confirm_register_form``: Confirmable register form +* ``register_form``: Register form +* ``forgot_password_form``: Forgot password form +* ``reset_password_form``: Reset password form +* ``send_confirmation_form``: Send confirmation form +* ``passwordless_login_form``: Passwordless login form + + Emails ------ @@ -93,4 +124,4 @@ templates you can specify an email context processor with the # This processor is added to all emails @security.email_context_processor def security_mail_processor(): - return dict(hello="world") \ No newline at end of file + return dict(hello="world") diff --git a/flask_security/core.py b/flask_security/core.py index 6b58b3d..60d6530 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -21,6 +21,9 @@ 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 # Convenient references _security = LocalProxy(lambda: current_app.extensions['security']) @@ -105,6 +108,17 @@ _allowed_password_hash_schemes = [ 'plaintext' ] +_default_forms = { + 'login_form': LoginForm, + 'confirm_register_form': ConfirmRegisterForm, + 'register_form': RegisterForm, + 'forgot_password_form': ForgotPasswordForm, + 'reset_password_form': ResetPasswordForm, + 'send_confirmation_form': SendConfirmationForm, + 'passwordless_login_form': PasswordlessLoginForm, +} + + def _user_loader(user_id): return _security.datastore.find_user(id=user_id) @@ -189,6 +203,10 @@ def _get_state(app, datastore, **kwargs): _send_mail_task=None )) + for key, value in _default_forms.items(): + if key not in kwargs or not kwargs[key]: + kwargs[key] = value + return _SecurityState(**kwargs) @@ -296,12 +314,17 @@ class Security(object): if app is not None and datastore is not None: self._state = self.init_app(app, datastore, **kwargs) - def init_app(self, app, datastore=None, register_blueprint=True): + 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): """Initializes the Flask-Security extension for the specified application and datastore implentation. :param app: The application. :param datastore: An instance of a user datastore. + :param register_blueprint: to register the Security blueprint or not. """ datastore = datastore or self.datastore @@ -313,7 +336,14 @@ class Security(object): identity_loaded.connect_via(app)(_on_identity_loaded) - state = _get_state(app, datastore) + state = _get_state(app, datastore, + login_form=login_form, + confirm_register_form=confirm_register_form, + register_form=register_form, + forgot_password_form=forgot_password_form, + reset_password_form=reset_password_form, + send_confirmation_form=send_confirmation_form, + passwordless_login_form=passwordless_login_form) if register_blueprint: app.register_blueprint(create_blueprint(state, __name__)) diff --git a/flask_security/forms.py b/flask_security/forms.py index 3539f63..64ae022 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -9,10 +9,12 @@ :license: MIT, see LICENSE for more details. """ +import inspect + from flask import request, current_app from flask.ext.wtf import Form as BaseForm, TextField, PasswordField, \ SubmitField, HiddenField, Required, BooleanField, EqualTo, Email, \ - ValidationError, Length + ValidationError, Length, Field from werkzeug.local import LocalProxy from .confirmable import requires_confirmation @@ -93,6 +95,13 @@ class NextFormMixin(): class RegisterFormMixin(): submit = SubmitField("Register") + def to_dict(form): + def is_field_and_user_attr(member): + 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) + class SendConfirmationForm(Form, UserEmailFormMixin): """The default forgot password form""" @@ -176,10 +185,7 @@ class LoginForm(Form, NextFormMixin): class ConfirmRegisterForm(Form, RegisterFormMixin, UniqueEmailFormMixin, NewPasswordFormMixin): - def to_dict(self): - return dict(email=self.email.data, - password=self.password.data) - + pass class RegisterForm(ConfirmRegisterForm, PasswordConfirmFormMixin): pass diff --git a/flask_security/views.py b/flask_security/views.py index c079ea7..3efd316 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -17,9 +17,6 @@ from werkzeug.local import LocalProxy from .confirmable import send_confirmation_instructions, \ confirm_user, confirm_email_token_status from .decorators import login_required, anonymous_user_required -from .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ - ForgotPasswordForm, ResetPasswordForm, SendConfirmationForm, \ - PasswordlessLoginForm from .passwordless import send_login_instructions, \ login_token_status from .recoverable import reset_password_token_status, \ @@ -64,10 +61,12 @@ def _ctx(endpoint): def login(): """View function for login view""" + form_class = _security.login_form + if request.json: - form = LoginForm(MultiDict(request.json)) + form = form_class(MultiDict(request.json)) else: - form = LoginForm() + form = form_class() if form.validate_on_submit(): login_user(form.user, remember=form.remember.data) @@ -101,9 +100,9 @@ def register(): """View function which handles a registration request.""" if _security.confirmable or request.json: - form_class = ConfirmRegisterForm + form_class = _security.confirm_register_form else: - form_class = RegisterForm + form_class = _security.register_form if request.json: form_data = MultiDict(request.json) @@ -136,10 +135,12 @@ def register(): def send_login(): """View function that sends login instructions for passwordless login""" + form_class = _security.passwordless_login_form + if request.json: - form = PasswordlessLoginForm(MultiDict(request.json)) + form = form_class(MultiDict(request.json)) else: - form = PasswordlessLoginForm() + form = form_class() if form.validate_on_submit(): send_login_instructions(form.user) @@ -179,10 +180,12 @@ def token_login(token): def send_confirmation(): """View function which sends confirmation instructions.""" + form_class = _security.send_confirmation_form + if request.json: - form = SendConfirmationForm(MultiDict(request.json)) + form = form_class(MultiDict(request.json)) else: - form = SendConfirmationForm() + form = form_class() if form.validate_on_submit(): send_confirmation_instructions(form.user) @@ -225,10 +228,12 @@ def confirm_email(token): def forgot_password(): """View function that handles a forgotten password request.""" + form_class = _security.forgot_password_form + if request.json: - form = ForgotPasswordForm(MultiDict(request.json)) + form = form_class(MultiDict(request.json)) else: - form = ForgotPasswordForm() + form = form_class() if form.validate_on_submit(): send_reset_password_instructions(form.user) @@ -257,7 +262,7 @@ def reset_password(token): if invalid or expired: return redirect(url_for('forgot_password')) - form = ResetPasswordForm() + form = _security.reset_password_form() if form.validate_on_submit(): after_this_request(_commit) diff --git a/tests/__init__.py b/tests/__init__.py index 6d1f451..53e0d9b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,20 +5,24 @@ from tests.test_app.sqlalchemy import create_app class SecurityTest(TestCase): + APP_KWARGS = { + 'register_blueprint': True, + } AUTH_CONFIG = None def setUp(self): super(SecurityTest, self).setUp() - app = self._create_app(self.AUTH_CONFIG or {}) + app_kwargs = self.APP_KWARGS + app = self._create_app(self.AUTH_CONFIG or {}, **app_kwargs) app.debug = False app.config['TESTING'] = True self.app = app self.client = app.test_client() - def _create_app(self, auth_config, register_blueprint=True): - return create_app(auth_config, register_blueprint) + def _create_app(self, auth_config, **kwargs): + return create_app(auth_config, **kwargs) def _get(self, route, content_type=None, follow_redirects=None, headers=None): return self.client.get(route, follow_redirects=follow_redirects, diff --git a/tests/configured_tests.py b/tests/configured_tests.py index 45f0fc6..f1e0d7d 100644 --- a/tests/configured_tests.py +++ b/tests/configured_tests.py @@ -6,6 +6,11 @@ import simplejson as json from flask.ext.security.utils import capture_registrations, \ capture_reset_password_requests, capture_passwordless_login_requests +from flask.ext.security.forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ + ForgotPasswordForm, ResetPasswordForm, SendConfirmationForm, \ + PasswordlessLoginForm +from flask.ext.security.forms import TextField, SubmitField, valid_user_email + from tests import SecurityTest @@ -468,13 +473,14 @@ class AsyncMailTaskTests(SecurityTest): class NoBlueprintTests(SecurityTest): + APP_KWARGS = { + 'register_blueprint': False, + } + AUTH_CONFIG = { 'USER_COUNT': 1 } - def _create_app(self, auth_config): - return super(NoBlueprintTests, self)._create_app(auth_config, False) - def test_login_endpoint_is_404(self): r = self._get('/login') self.assertEqual(404, r.status_code) @@ -483,3 +489,109 @@ class NoBlueprintTests(SecurityTest): auth = 'Basic ' + base64.b64encode("matt@lp.com:password") r = self._get('/http', headers={'Authorization': auth}) self.assertIn('HTTP Authentication', r.data) + + +class ExtendFormsTest(SecurityTest): + + class MyLoginForm(LoginForm): + email = TextField('My Login Email Address Field') + + class MyRegisterForm(RegisterForm): + email = TextField('My Register Email Address Field') + + APP_KWARGS = { + 'login_form': MyLoginForm, + 'register_form': MyRegisterForm, + } + + AUTH_CONFIG = { + 'SECURITY_CONFIRMABLE': False, + 'SECURITY_REGISTERABLE': True, + } + + def test_login_view(self): + r = self._get('/login', follow_redirects=True) + self.assertIn("My Login Email Address Field", r.data) + + def test_register(self): + r = self._get('/register', follow_redirects=True) + self.assertIn("My Register Email Address Field", r.data) + + +class RecoverableExtendFormsTest(SecurityTest): + + class MyForgotPasswordForm(ForgotPasswordForm): + email = TextField('My Forgot Password Email Address Field', + validators=[valid_user_email]) + + class MyResetPasswordForm(ResetPasswordForm): + submit = SubmitField("My Reset Password Submit Field") + + APP_KWARGS = { + 'forgot_password_form': MyForgotPasswordForm, + 'reset_password_form': MyResetPasswordForm, + } + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + } + + def test_forgot_password(self): + r = self._get('/reset', follow_redirects=True) + self.assertIn("My Forgot Password Email Address Field", r.data) + + def test_reset_password(self): + with capture_reset_password_requests() as requests: + self.client.post('/reset', + data=dict(email='joe@lp.com'), + follow_redirects=True) + token = requests[0]['token'] + r = self._get('/reset/' + token) + self.assertIn("My Reset Password Submit Field", r.data) + + +class PasswordlessExtendFormsTest(SecurityTest): + + class MyPasswordlessLoginForm(PasswordlessLoginForm): + email = TextField('My Passwordless Login Email Address Field') + + APP_KWARGS = { + 'passwordless_login_form': MyPasswordlessLoginForm, + } + + AUTH_CONFIG = { + 'SECURITY_PASSWORDLESS': True, + } + + def test_passwordless_login(self): + r = self._get('/login', follow_redirects=True) + self.assertIn("My Passwordless Login Email Address Field", r.data) + + +class ConfirmableExtendFormsTest(SecurityTest): + + class MyConfirmRegisterForm(ConfirmRegisterForm): + email = TextField('My Confirm Register Email Address Field') + + class MySendConfirmationForm(SendConfirmationForm): + email = TextField('My Send Confirmation Email Address Field') + + APP_KWARGS = { + 'confirm_register_form': MyConfirmRegisterForm, + 'send_confirmation_form': MySendConfirmationForm, + } + + AUTH_CONFIG = { + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, + } + + def test_register(self): + r = self._get('/register', follow_redirects=True) + self.assertIn("My Confirm Register Email Address Field", r.data) + + + def test_send_confirmation(self): + r = self._get('/confirm', follow_redirects=True) + self.assertIn("My Send Confirmation Email Address Field", r.data) + diff --git a/tests/functional_tests.py b/tests/functional_tests.py index c9fd8da..49e9be6 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -201,9 +201,9 @@ class DefaultSecurityTests(SecurityTest): class MongoEngineSecurityTests(DefaultSecurityTests): - def _create_app(self, auth_config): + def _create_app(self, auth_config, **kwargs): from tests.test_app.mongoengine import create_app - return create_app(auth_config) + return create_app(auth_config, **kwargs) class DefaultDatastoreTests(SecurityTest): @@ -231,6 +231,6 @@ class DefaultDatastoreTests(SecurityTest): class MongoEngineDatastoreTests(DefaultDatastoreTests): - def _create_app(self, auth_config): + def _create_app(self, auth_config, **kwargs): from tests.test_app.mongoengine import create_app - return create_app(auth_config) + return create_app(auth_config, **kwargs) diff --git a/tests/test_app/mongoengine.py b/tests/test_app/mongoengine.py index 52377f2..d40f666 100644 --- a/tests/test_app/mongoengine.py +++ b/tests/test_app/mongoengine.py @@ -13,7 +13,7 @@ from flask.ext.security import Security, UserMixin, RoleMixin, \ from tests.test_app import create_app as create_base_app, populate_data, \ add_context_processors -def create_app(config): +def create_app(config, **kwargs): app = create_base_app(config) app.config['MONGODB_SETTINGS'] = dict( @@ -46,7 +46,7 @@ def create_app(config): Role.drop_collection() populate_data(app.config.get('USER_COUNT', None)) - app.security = Security(app, MongoEngineUserDatastore(db, User, Role)) + app.security = Security(app, datastore=MongoEngineUserDatastore(db, User, Role), **kwargs) add_context_processors(app.security) diff --git a/tests/test_app/sqlalchemy.py b/tests/test_app/sqlalchemy.py index 0cf2e9c..ec23b01 100644 --- a/tests/test_app/sqlalchemy.py +++ b/tests/test_app/sqlalchemy.py @@ -14,10 +14,11 @@ from flask.ext.security import Security, UserMixin, RoleMixin, \ from tests.test_app import create_app as create_base_app, populate_data, \ add_context_processors -def create_app(config, register_blueprint=True): +def create_app(config, **kwargs): app = create_base_app(config) - app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root@localhost/flask_security_test' + #app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root@localhost/flask_security_test' + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' db = SQLAlchemy(app) @@ -50,8 +51,7 @@ def create_app(config, register_blueprint=True): db.create_all() populate_data(app.config.get('USER_COUNT', None)) - app.security = Security(app, SQLAlchemyUserDatastore(db, User, Role), - register_blueprint=register_blueprint) + app.security = Security(app, datastore=SQLAlchemyUserDatastore(db, User, Role), **kwargs) add_context_processors(app.security)