-
Notifications
You must be signed in to change notification settings - Fork 70
Make password reset tokens single-use #687
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
|
|
||
| import functools | ||
| import logging | ||
| import secrets | ||
| from typing import Optional | ||
| from blinker import signal | ||
| from flask import current_app, render_template, Flask | ||
|
|
@@ -159,15 +160,30 @@ def send_confirmation_email(app, user, url, template, header, **kwargs): | |
| Send confirmation email from selected template with customizable email subject and confirmation URL. | ||
| Optional kwargs are passed to render_template method if needed for particular template. | ||
| """ | ||
| from ..celery import send_email_async | ||
| token = generate_confirmation_token( | ||
| app, user.email, app.config["SECURITY_EMAIL_SALT"] | ||
| ) | ||
| _send_token_email(app, user, f"{url}/{token}", template, header, **kwargs) | ||
|
|
||
|
|
||
| salt = ( | ||
| app.config["SECURITY_EMAIL_SALT"] | ||
| if url == "confirm-email" | ||
| else app.config["SECURITY_PASSWORD_SALT"] | ||
| def send_password_reset_email(app: Flask, user: User) -> None: | ||
| """Issue a new single-use password reset token (revoking any previous one) and email it.""" | ||
| from ..app import db | ||
|
|
||
| token = generate_password_reset_token(app, user) | ||
| db.session.commit() | ||
| _send_token_email( | ||
| app, | ||
| user, | ||
| f"change-password/{token}", | ||
| "email/password_reset.html", | ||
| "Password reset", | ||
| ) | ||
| token = generate_confirmation_token(app, user.email, salt) | ||
| confirm_url = f"{url}/{token}" | ||
|
|
||
|
|
||
| def _send_token_email(app, user, confirm_url, template, header, **kwargs): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we have such function, I am expecting here this token value as argument. Not hidden in some tricky string. |
||
| from ..celery import send_email_async | ||
|
|
||
| html = render_template( | ||
| template, subject=header, confirm_url=confirm_url, user=user, **kwargs | ||
| ) | ||
|
|
@@ -180,6 +196,34 @@ def send_confirmation_email(app, user, url, template, header, **kwargs): | |
| send_email_async.delay(**email_data) | ||
|
|
||
|
|
||
| def generate_password_reset_token(app: Flask, user: User) -> str: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not better to add it to user model? Just wondering ;) |
||
| """Sign a reset token bound to a fresh nonce stored on the user. | ||
|
|
||
| Rotating the nonce revokes previously issued tokens; any password change clears it. | ||
| """ | ||
| user.password_reset_nonce = secrets.token_urlsafe(32) | ||
| serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"]) | ||
| payload = {"email": user.email, "nonce": user.password_reset_nonce} | ||
| return serializer.dumps(payload, salt=app.config["SECURITY_PASSWORD_SALT"]) | ||
|
|
||
|
|
||
| def confirm_password_reset_token(token: str) -> Optional[dict]: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not enough tom just use |
||
| """Return a signed, unexpired reset token payload, or None. The nonce still has to be checked against the user.""" | ||
| serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) | ||
| try: | ||
| payload = serializer.loads( | ||
| token, | ||
| salt=current_app.config["SECURITY_PASSWORD_SALT"], | ||
| max_age=current_app.config["PASSWORD_RESET_TOKEN_EXPIRATION"], | ||
| ) | ||
| except BadData: | ||
| return None | ||
| # tokens issued before nonces were introduced carried only the email | ||
| if not isinstance(payload, dict): | ||
| return None | ||
| return payload | ||
|
|
||
|
|
||
| def generate_unlock_token(app: Flask, user: User) -> str: | ||
| """Sign a token binding the current lock episode (email + locked_until) to the user.""" | ||
| serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"]) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,9 @@ class Configuration(object): | |
| BEARER_TOKEN_EXPIRATION = config( | ||
| "BEARER_TOKEN_EXPIRATION", default=3600 * 12, cast=int | ||
| ) # in seconds | ||
| PASSWORD_RESET_TOKEN_EXPIRATION = config( | ||
| "PASSWORD_RESET_TOKEN_EXPIRATION", default=600, cast=int | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is short window.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can make it 1800 or 3600 if you like -> but if you hit reset button, then I assume you want to do it straightaway |
||
| ) # in seconds | ||
| ACCOUNT_EXPIRATION = config("ACCOUNT_EXPIRATION", default=5, cast=int) # in days | ||
| BCRYPT_LOG_ROUNDS = config("BCRYPT_LOG_ROUNDS", default=12, cast=int) | ||
| # Comma-separated "attempts:seconds" pairs, e.g. "5:300,10:3600" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,9 @@ | |
| auth_required, | ||
| authenticate, | ||
| send_confirmation_email, | ||
| send_password_reset_email, | ||
| confirm_token, | ||
| confirm_password_reset_token, | ||
| generate_confirmation_token, | ||
| confirm_unlock_token, | ||
| user_created, | ||
|
|
@@ -380,19 +382,13 @@ def password_reset(): # pylint: disable=W0613,W0612 | |
| target_email=form.email.data.strip(), | ||
| ) | ||
| if user and user.active and user.can_edit_profile: | ||
| send_confirmation_email( | ||
| current_app, | ||
| user, | ||
| "change-password", | ||
| "email/password_reset.html", | ||
| "Password reset", | ||
| ) | ||
| send_password_reset_email(current_app, user) | ||
| return "", 200 | ||
|
|
||
|
|
||
| def confirm_new_password(token): # pylint: disable=W0613,W0612 | ||
| email = confirm_token(token, salt=current_app.config["SECURITY_PASSWORD_SALT"]) | ||
| if not email: | ||
| payload = confirm_password_reset_token(token) | ||
| if not payload: | ||
| emit( | ||
| AuthEventType.USER_PASSWORD_RESET_FAILED, | ||
| **request_context(), | ||
|
|
@@ -402,6 +398,7 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612 | |
| ) | ||
| abort(400, "Invalid token") | ||
|
|
||
| email = payload["email"] | ||
| user = User.query.filter_by(email=email).first() | ||
| if not user: | ||
| emit( | ||
|
|
@@ -412,6 +409,16 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612 | |
| reason="user_not_found", | ||
| ) | ||
| abort(404) | ||
| # token was already used, superseded by a newer one or revoked by a password change | ||
| if user.password_reset_nonce != payload["nonce"]: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can be payload["nonce"] None? If yes, reject everything too.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that can be only for old tokens, but any new request should have some |
||
| emit( | ||
| AuthEventType.USER_PASSWORD_RESET_FAILED, | ||
| **request_context(), | ||
| target_user_id=user.id, | ||
| target_email=user.email, | ||
| reason="token_revoked", | ||
| ) | ||
| abort(400, "Invalid token") | ||
| if not user.active: | ||
| emit( | ||
| AuthEventType.USER_PASSWORD_RESET_FAILED, | ||
|
|
@@ -433,6 +440,7 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612 | |
|
|
||
| form = UserPasswordForm.from_json(request.json) | ||
| if form.validate(): | ||
| # also clears the reset nonce, making the token single-use | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we do not need such comment here. |
||
| user.assign_password(form.password.data) | ||
| user.reset_lockout() | ||
| db.session.add(user) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.