Skip to content
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

Make it possible for a user to reset its password #198

Merged
merged 11 commits into from Sep 12, 2017
@@ -42,6 +42,39 @@
# `config.py` sits.
# base_dir =

# The following settings are used for sending outbound emails for password reset
# emails.
# mail_server = localhost
# mail_port = 25
# mail_use_tls = false
# mail_use_ssl = false
# mail_username =
# mail_password =
# mail_default_sender =
# mail_max_emails =

# This is the template that gets send to the users if they request a new
# password. Double newlines are replaces by '<br><br>', this is the html part of
# the email, the txt part gets send automatically. You can use the following
# data in this template (all between {}):
# - user_name: The name of the user
# - user_email: The email of the user
# - user_id: The name of the user
# - site_url: The url of the site
# - url: The url to reset the password
# - token: The token to reset the password with
# email_template = <p>Dear {user_name},
#
# This email lets you reset your password on <a
# href="{site_url}">site_url</a>. If you goto <a href="{url}">this page</a>
# you can reset your password there. Please do not reply to this email.
#
# If you have not triggered this action please ignore this email.</p>

# Time in seconds a user has to reset his or her password after the e-mail is
# send in seconds.
# reset_token_time = 86400

# Define the database. If `CODEGRADE_DATABASE_URL` is found in the enviroment
# variables it is used. The string should be in this format for postgresql:
# `postgresql://dbusername:dbpassword@dbhost/dbname`
@@ -139,6 +139,31 @@ def set_str(
['git', 'describe', '--abbrev=0', '--tags']
).decode('utf-8').strip()

# Set email settings
set_str(CONFIG, backend_ops, 'MAIL_SERVER', 'localhost')
set_int(CONFIG, backend_ops, 'MAIL_PORT', 25)
set_bool(CONFIG, backend_ops, 'MAIL_USE_TLS', False)
set_bool(CONFIG, backend_ops, 'MAIL_USE_SSL', False)
set_str(CONFIG, backend_ops, 'MAIL_USERNAME', None)
set_str(CONFIG, backend_ops, 'MAIL_PASSWORD', None)
set_str(CONFIG, backend_ops, 'MAIL_DEFAULT_SENDER', None)
set_str(CONFIG, backend_ops, 'MAIL_MAX_EMAILS', None)
set_int(CONFIG, backend_ops, 'RESET_TOKEN_TIME', 86400)
set_str(
CONFIG,
backend_ops,
'EMAIL_TEMPLATE',
"""
<p>Dear {user_name},
This email lets you reset your password on <a
href="{site_url}">site_url</a>. If you goto <a href="{url}">this page</a>
you can reset your password there. Please do not reply to this email.
If you have not triggered this action please ignore this email.</p>
""".strip(),
)

############
# FEATURES #
############
@@ -0,0 +1,27 @@
"""Add reset_token column
Revision ID: 5fce393529d2
Revises: abcddf37ebfa
Create Date: 2017-09-11 18:52:17.494441
"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = '5fce393529d2'
down_revision = 'abcddf37ebfa'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('User', sa.Column('reset_token', sa.String(length=36), nullable=True))
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('User', 'reset_token')
# ### end Alembic commands ###
@@ -4,6 +4,7 @@
import typing as t
import os
import flask_jwt_extended as flask_jwt
from flask_mail import Mail

import datetime
import json
@@ -15,11 +16,14 @@
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
mail = Mail()

# Configurations
app.config.update(config.CONFIG)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

mail.init_app(app)

jwt = flask_jwt.JWTManager(app)

# Define the database object which is imported
@@ -27,6 +27,7 @@ class APICodes(IntEnum):
INVALID_STATE = 13
INVALID_OAUTH_REQUEST = 14
DISABLED_FEATURE = 15
UNKOWN_ERROR = 16


class APIException(Exception):
@@ -10,6 +10,7 @@
import threading
from concurrent import futures

from itsdangerous import URLSafeTimedSerializer
from sqlalchemy_utils import PasswordType
from sqlalchemy.sql.expression import or_, and_, func, null, false
from sqlalchemy.orm.collections import attribute_mapped_collection
@@ -506,6 +507,11 @@ class User(Base):
nullable=False,
index=True,
)

reset_token: str = db.Column(
'reset_token', db.String(UUID_LENGTH), nullable=True
)

email: str = db.Column('email', db.Unicode, unique=False, nullable=False)
password: str = db.Column(
'password',
@@ -706,6 +712,58 @@ def get_all_permissions(self, course_id: t.Union['Course', int]=None
course_permission=True).all()
return {perm.name: False for perm in perms}

def get_reset_token(self) -> str:
"""Get a token which a user can use to reset his password.
.. note:: Don't forget to commit the database.
:returns: A token that can be used in :py:meth:`User.reset_password` to
reset the password of a user.
"""
ts = URLSafeTimedSerializer(psef.app.config['SECRET_KEY'])
self.reset_token = str(uuid.uuid4())
return str(ts.dumps(self.username, salt=self.reset_token))

def reset_password(self, token: str, new_password: str) -> None:
"""Reset a users password by using a token.
.. note:: Don't forget to commit the database.
:param token: A token as generated by :py:meth:`User.get_reset_token`.
:param new_password: The new password to set.
:returns: Nothing.
:raises psef.auth.PermissionException: If something was wrong with the
given token.
"""
ts = URLSafeTimedSerializer(psef.app.config['SECRET_KEY'])
try:
username = ts.loads(
token,
max_age=psef.app.config['RESET_TOKEN_TIME'],
salt=self.reset_token
)
except:
import traceback
traceback.print_exc()
raise psef.auth.PermissionException(
'The given token is not valid',
f'The given token {token} is not valid.',
psef.errors.APICodes.INVALID_CREDENTIALS, 403
)

# This should never happen but better safe than sorry.
if (username != self.username or
self.reset_token is None): # pragma: no cover
raise psef.auth.PermissionException(
'The given token is not valid for this user',
f'The given token {token} is not valid for user "{self.id}".',
psef.errors.APICodes.INVALID_CREDENTIALS, 403
)

self.password = new_password
self.reset_token = None

@property
def is_active(self) -> bool:
return self.active
@@ -1519,7 +1577,9 @@ class AssignmentLinter(Base):
query = Base.query # type: t.ClassVar[_MyQuery['AssignmentLinter']]
__tablename__ = 'AssignmentLinter' # type: str
# This has to be a String object as the id has to be a non guessable uuid.
id: str = db.Column('id', db.String(36), nullable=False, primary_key=True)
id: str = db.Column(
'id', db.String(UUID_LENGTH), nullable=False, primary_key=True
)
name: str = db.Column('name', db.Unicode)
tests = db.relationship(
"LinterInstance",
@@ -1619,7 +1679,9 @@ class LinterInstance(Base):
if t.TYPE_CHECKING: # pragma: no cover
query = Base.query # type: t.ClassVar[_MyQuery['LinterInstance']]
__tablename__ = 'LinterInstance'
id: str = db.Column('id', db.String(36), nullable=False, primary_key=True)
id: str = db.Column(
'id', db.String(UUID_LENGTH), nullable=False, primary_key=True
)
state: LinterState = db.Column(
'state',
db.Enum(LinterState),
@@ -5,11 +5,15 @@
"""
import typing as t

import html2text
from flask import request
from flask_mail import Message
from validate_email import validate_email

import psef
import psef.auth as auth
import psef.models as models
import psef.helpers as helpers
from psef import db, jwt, current_user
from psef.errors import APICodes, APIException
from psef.helpers import (
@@ -113,14 +117,66 @@ def me() -> JSONResponse[t.Union[models.User, t.Mapping[int, str],
return jsonify(current_user)


@api.route('/login', methods=['PATCH'])
def get_user_update() -> EmptyResponse:
"""Change data of the current :class:`.models.User`.
.. :quickref: User; Update the currently logged users information.
def send_reset_password_email(user: models.User) -> None:
token = user.get_reset_token()
html_body = psef.app.config['EMAIL_TEMPLATE'].replace(
'\n\n', '<br><br>'
).format(
site_url=psef.app.config["EXTERNAL_URL"],
url=f'{psef.app.config["EXTERNAL_URL"]}/reset_'
f'password/?user={user.id}&token={token}',
user_id=user.id,
token=token,
user_name=user.name,
user_email=user.email,
)
text_maker = html2text.HTML2Text(bodywidth=78)
text_maker.inline_links = False
text_maker.wrap_links = False

message = Message(
subject=f'Reset password on {psef.app.config["EXTERNAL_URL"]}',
body=text_maker.handle(html_body),
html=html_body,
recipients=[user.email],
)
try:
psef.mail.send(message)
except:
raise APIException(
'Something went wrong sending the email, '
'please contact your site admin',
f'Sending email to {user.id} went wrong.',
APICodes.UNKOWN_ERROR,
500,
)
db.session.commit()

:returns: An empty response with return code 204

@api.route('/login', methods=['PATCH'])
def get_user_update(
) -> t.Union[EmptyResponse, JSONResponse[t.Mapping[str, str]]]:
"""Change data of the current :class:`.models.User` and handle passsword
resets.
.. :quickref: User; Update the currently logged users information or reset
a password.
- If ``type`` is ``reset_password`` reset the password of the user with the
given user_id with the given token to the given ``new_password``.
- If ``type`` is ``reset_email`` send a email to the user with the given
username that enables this user to reset its password.
- Otherwise change user info of the currently logged in user.
:returns: An empty response with return code 204 unless ``type`` is
``reset_password``, in this case a mapping between ``access_token`` and
a jwt token is returned.
:<json int user_id: The id of the user, only when type is reset_password.
:<json str username: The username of the user, only when type is
reset_email.
:<json str token : The reset password token. Only if type is
reset_password.
:<json str email: The new email of the user.
:<json str name: The new full name of the user.
:<json str old_password: The old password of the user.
@@ -139,6 +195,44 @@ def get_user_update() -> EmptyResponse:
"""
data = ensure_json_dict(request.get_json())

if request.args.get('type', None) == 'reset_email':
ensure_keys_in_dict(data, [('username', str)])
send_reset_password_email(
helpers.filter_single_or_404(
models.User, models.User.username == data['username']
)
)
return make_empty_response()
elif request.args.get('type', None) == 'reset_password':
ensure_keys_in_dict(
data, [('new_password', str),
('token', str),
('user_id', int)]
)

password = t.cast(str, data['new_password'])
user_id = t.cast(int, data['user_id'])
token = t.cast(str, data['token'])

if password == '':
raise APIException(
'Password should at least be 1 char',
f'The password is {len(password)} chars long',
APICodes.INVALID_PARAM, 400
)
user = helpers.get_or_404(models.User, user_id)
user.reset_password(token, password)
db.session.commit()
return jsonify(
{
'access_token':
jwt.create_access_token(
identity=user.id,
fresh=True,
)
}
)

ensure_keys_in_dict(
data, [
('email', str),
Oops, something went wrong.
ProTip! Use n and p to navigate between commits in a pull request.
You can’t perform that action at this time.