diff --git a/flask_security/core.py b/flask_security/core.py index 80d3e1db..f410f15b 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -25,8 +25,10 @@ from werkzeug.datastructures import ImmutableList from werkzeug.local import LocalProxy -from .forms import ChangePasswordForm, ConfirmRegisterForm, \ - ForgotPasswordForm, LoginForm, PasswordlessLoginForm, RegisterForm, \ +from .forms import ChangePasswordForm, \ + EmailConfirmRegisterForm, UsernameConfirmRegisterForm, \ + ForgotPasswordForm, EmailLoginForm, UsernameLoginForm, \ + PasswordlessLoginForm, EmailRegisterForm, UsernameRegisterForm, \ ResetPasswordForm, SendConfirmationForm from .utils import config_value as cv from .utils import _, get_config, hash_data, localize_callback, string_types, \ @@ -142,6 +144,8 @@ _('Invalid confirmation token.'), 'error'), 'EMAIL_ALREADY_ASSOCIATED': ( _('%(email)s is already associated with an account.'), 'error'), + 'USERNAME_ALREADY_IN_USE': ( + _('%(username)s is already in use.'), 'error'), 'PASSWORD_MISMATCH': ( _('Password does not match'), 'error'), 'RETYPE_PASSWORD_MISMATCH': ( @@ -177,6 +181,10 @@ _('Email not provided'), 'error'), 'INVALID_EMAIL_ADDRESS': ( _('Invalid email address'), 'error'), + 'USERNAME_NOT_PROVIDED': ( + _('Username not provided'), 'error'), + 'INVALID_USERNAME': ( + _('Invalid username'), 'error'), 'PASSWORD_NOT_PROVIDED': ( _('Password not provided'), 'error'), 'PASSWORD_NOT_SET': ( @@ -205,10 +213,21 @@ _('Please reauthenticate to access this page.'), 'info'), } -_default_forms = { - 'login_form': LoginForm, - 'confirm_register_form': ConfirmRegisterForm, - 'register_form': RegisterForm, +_default_email_forms = { + 'login_form': EmailLoginForm, + 'confirm_register_form': EmailConfirmRegisterForm, + 'register_form': EmailRegisterForm, + 'forgot_password_form': ForgotPasswordForm, + 'reset_password_form': ResetPasswordForm, + 'change_password_form': ChangePasswordForm, + 'send_confirmation_form': SendConfirmationForm, + 'passwordless_login_form': PasswordlessLoginForm, +} + +_default_username_forms = { + 'login_form': UsernameLoginForm, + 'confirm_register_form': UsernameConfirmRegisterForm, + 'register_form': UsernameRegisterForm, 'forgot_password_form': ForgotPasswordForm, 'reset_password_form': ResetPasswordForm, 'change_password_form': ChangePasswordForm, @@ -342,7 +361,16 @@ def _get_state(app, datastore, anonymous_user=None, **kwargs): _unauthorized_callback=None )) - for key, value in _default_forms.items(): + ident_attrs = app.config.get( + "SECURITY_USER_IDENTITY_ATTRIBUTES", + ["email"], + ) + if ident_attrs == ["email"]: + default_forms = _default_email_forms + else: + default_forms = _default_username_forms + + for key, value in default_forms.items(): if key not in kwargs or not kwargs[key]: kwargs[key] = value diff --git a/flask_security/datastore.py b/flask_security/datastore.py index c8109eb8..06d31939 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -118,7 +118,8 @@ def __init__(self, user_model, role_model): def _prepare_role_modify_args(self, user, role): if isinstance(user, string_types): - user = self.find_user(email=user) + user_kwargs = {attr: user for attr in get_identity_attributes()} + user = self.find_user(**user_kwargs) if isinstance(role, string_types): role = self.find_role(role) return user, role diff --git a/flask_security/forms.py b/flask_security/forms.py index 903b3636..c7cc9f73 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -28,6 +28,7 @@ _default_field_labels = { 'email': _('Email Address'), + 'username': _('Username'), 'password': _('Password'), 'remember_me': _('Remember Me'), 'login': _('Login'), @@ -61,12 +62,18 @@ class Email(ValidatorMixin, validators.Email): pass +class Regexp(ValidatorMixin, validators.Regexp): + pass + + class Length(ValidatorMixin, validators.Length): pass email_required = Required(message='EMAIL_NOT_PROVIDED') email_validator = Email(message='INVALID_EMAIL_ADDRESS') +username_required = Required(message='USERNAME_NOT_PROVIDED') +username_validator = Regexp(r"[A-Za-z0-9_]+", message='INVALID_USERNAME') password_required = Required(message='PASSWORD_NOT_PROVIDED') password_length = Length(min=6, max=128, message='PASSWORD_INVALID_LENGTH') @@ -81,12 +88,21 @@ def unique_user_email(form, field): raise ValidationError(msg) +def unique_user_username(form, field): + if _datastore.get_user(field.data) is not None: + msg = get_message('USERNAME_ALREADY_IN_USE', username=field.data)[0] + raise ValidationError(msg) + + def valid_user_email(form, field): form.user = _datastore.get_user(field.data) if form.user is None: raise ValidationError(get_message('USER_DOES_NOT_EXIST')[0]) +valid_user_username = valid_user_email + + class Form(BaseForm): def __init__(self, *args, **kwargs): if current_app.testing: @@ -94,6 +110,12 @@ def __init__(self, *args, **kwargs): super(Form, self).__init__(*args, **kwargs) +class IdentifierForm(Form): + def __init__(self, *args, **kwargs): + super(IdentifierForm, self).__init__(*args, **kwargs) + setattr(self, "identifier", getattr(self, self.identifier_field)) + + class EmailFormMixin(): email = StringField( get_form_field_label('email'), @@ -105,12 +127,41 @@ class UserEmailFormMixin(): email = StringField( get_form_field_label('email'), validators=[email_required, email_validator, valid_user_email]) + identifier_field = "email" class UniqueEmailFormMixin(): email = StringField( get_form_field_label('email'), validators=[email_required, email_validator, unique_user_email]) + identifier_field = "email" + + +class UsernameFormMixin(): + username = StringField( + get_form_field_label('username'), + validators=[username_required, username_validator]) + + +class UserUsernameFormMixin(): + user = None + username = StringField( + get_form_field_label('username'), + validators=[ + username_required, username_validator, valid_user_username + ] + ) + identifier_field = "username" + + +class UniqueUsernameFormMixin(): + username = StringField( + get_form_field_label('username'), + validators=[ + username_required, username_validator, unique_user_username + ] + ) + identifier_field = "username" class PasswordFormMixin(): @@ -150,11 +201,12 @@ def is_field_and_user_attr(member): 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) + return dict((key, value.data) for key, value in fields + if key != "identifier") class SendConfirmationForm(Form, UserEmailFormMixin): - """The default forgot password form""" + """The default send confirmation form""" submit = SubmitField(get_form_field_label('send_confirmation')) @@ -172,20 +224,34 @@ def validate(self): return True -class ForgotPasswordForm(Form, UserEmailFormMixin): +class AbstractForgotPasswordForm(IdentifierForm): """The default forgot password form""" submit = SubmitField(get_form_field_label('recover_password')) def validate(self): - if not super(ForgotPasswordForm, self).validate(): + if not super(AbstractForgotPasswordForm, self).validate(): return False if requires_confirmation(self.user): - self.email.errors.append(get_message('CONFIRMATION_REQUIRED')[0]) + self.identifier.errors.append( + get_message('CONFIRMATION_REQUIRED')[0] + ) return False return True +class EmailForgotPasswordForm(AbstractForgotPasswordForm, UserEmailFormMixin): + pass + + +class UsernameForgotPasswordForm(AbstractForgotPasswordForm, + UserUsernameFormMixin): + pass + + +ForgotPasswordForm = EmailForgotPasswordForm + + class PasswordlessLoginForm(Form, UserEmailFormMixin): """The passwordless login form""" @@ -203,18 +269,16 @@ def validate(self): return True -class LoginForm(Form, NextFormMixin): +class AbstractLoginForm(IdentifierForm, NextFormMixin): """The default login form""" - email = StringField(get_form_field_label('email'), - validators=[Required(message='EMAIL_NOT_PROVIDED')]) password = PasswordField(get_form_field_label('password'), validators=[password_required]) remember = BooleanField(get_form_field_label('remember_me')) submit = SubmitField(get_form_field_label('login')) def __init__(self, *args, **kwargs): - super(LoginForm, self).__init__(*args, **kwargs) + super(AbstractLoginForm, self).__init__(*args, **kwargs) if not self.next.data: self.next.data = request.args.get('next', '') self.remember.default = config_value('DEFAULT_REMEMBER_ME') @@ -227,42 +291,77 @@ def __init__(self, *args, **kwargs): self.password.description = html def validate(self): - if not super(LoginForm, self).validate(): + if not super(AbstractLoginForm, self).validate(): return False - self.user = _datastore.get_user(self.email.data) + self.user = _datastore.get_user(self.identifier.data) if self.user is None: - self.email.errors.append(get_message('USER_DOES_NOT_EXIST')[0]) + self.identifier.errors.append( + get_message('USER_DOES_NOT_EXIST')[0] + ) return False if not self.user.password: - self.password.errors.append(get_message('PASSWORD_NOT_SET')[0]) + self.identifier.errors.append(get_message('PASSWORD_NOT_SET')[0]) return False if not verify_and_update_password(self.password.data, self.user): self.password.errors.append(get_message('INVALID_PASSWORD')[0]) return False if requires_confirmation(self.user): - self.email.errors.append(get_message('CONFIRMATION_REQUIRED')[0]) + self.identifier.errors.append( + get_message('CONFIRMATION_REQUIRED')[0] + ) return False if not self.user.is_active: - self.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) + self.identifier.errors.append(get_message('DISABLED_ACCOUNT')[0]) return False return True -class ConfirmRegisterForm(Form, RegisterFormMixin, - UniqueEmailFormMixin, NewPasswordFormMixin): +class EmailLoginForm(AbstractLoginForm, UserEmailFormMixin): + pass + + +class UsernameLoginForm(AbstractLoginForm, UserUsernameFormMixin): + pass + + +LoginForm = EmailLoginForm + + +class EmailConfirmRegisterForm(IdentifierForm, RegisterFormMixin, + UniqueEmailFormMixin, NewPasswordFormMixin): pass -class RegisterForm(ConfirmRegisterForm, PasswordConfirmFormMixin, - NextFormMixin): +class UsernameConfirmRegisterForm(IdentifierForm, RegisterFormMixin, + UniqueUsernameFormMixin, + NewPasswordFormMixin): + pass + + +ConfirmRegisterForm = EmailConfirmRegisterForm + + +class EmailRegisterForm(EmailConfirmRegisterForm, PasswordConfirmFormMixin, + NextFormMixin): + def __init__(self, *args, **kwargs): + super(EmailRegisterForm, self).__init__(*args, **kwargs) + if not self.next.data: + self.next.data = request.args.get('next', '') + + +class UsernameRegisterForm(UsernameConfirmRegisterForm, + PasswordConfirmFormMixin, NextFormMixin): def __init__(self, *args, **kwargs): - super(RegisterForm, self).__init__(*args, **kwargs) + super(UsernameRegisterForm, self).__init__(*args, **kwargs) if not self.next.data: self.next.data = request.args.get('next', '') +RegisterForm = EmailRegisterForm + + class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): """The default reset password form""" diff --git a/flask_security/templates/security/login_user.html b/flask_security/templates/security/login_user.html index 0962d2f5..2d5d513b 100644 --- a/flask_security/templates/security/login_user.html +++ b/flask_security/templates/security/login_user.html @@ -3,7 +3,7 @@