From 51944c7452a62045081f06b2dcd8b25237975d40 Mon Sep 17 00:00:00 2001 From: Alexander Ioannidis Date: Fri, 29 Nov 2019 11:48:07 +0100 Subject: [PATCH 01/10] utils: add "register_user" function * Adds a "register_user" function for allowing more flexible confirmation link sending. --- invenio_accounts/ext.py | 5 +++++ invenio_accounts/proxies.py | 7 +++++++ invenio_accounts/utils.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/invenio_accounts/ext.py b/invenio_accounts/ext.py index 544d6215..9af12775 100644 --- a/invenio_accounts/ext.py +++ b/invenio_accounts/ext.py @@ -264,6 +264,11 @@ def init_app(self, app, sessionstore=None, register_blueprint=False): :param register_blueprint: If ``True``, the application registers the blueprints. (Default: ``True``) """ + # Register the Flask-Security blueprint for the email templates + if not register_blueprint: + security_bp = Blueprint( + 'security', 'flask_security.core', template_folder='templates') + app.register_blueprint(security_bp) return super(InvenioAccountsREST, self).init_app( app, sessionstore=sessionstore, register_blueprint=register_blueprint, diff --git a/invenio_accounts/proxies.py b/invenio_accounts/proxies.py index b29ca38a..efc4533f 100644 --- a/invenio_accounts/proxies.py +++ b/invenio_accounts/proxies.py @@ -15,3 +15,10 @@ lambda: current_app.extensions['invenio-accounts'] ) """Proxy to the current Invenio-Accounts extension.""" + +current_security = LocalProxy(lambda: current_app.extensions['security']) +"""Proxy to the Flask-Security extension.""" + +current_datastore = LocalProxy( + lambda: current_app.extensions['security'].datastore) +"""Proxy to the current Flask-Security user datastore.""" diff --git a/invenio_accounts/utils.py b/invenio_accounts/utils.py index ff75669e..ef634193 100644 --- a/invenio_accounts/utils.py +++ b/invenio_accounts/utils.py @@ -14,11 +14,15 @@ import six from flask import current_app, session from flask_security import current_user +from flask_security.confirmable import generate_confirmation_token +from flask_security.utils import config_value as security_config_value +from flask_security.utils import hash_password from future.utils import raise_from from jwt import DecodeError, ExpiredSignatureError, decode, encode from werkzeug.utils import import_string from .errors import JWTDecodeError, JWTExpiredToken +from .proxies import current_datastore, current_security def jwt_create_token(user_id=None, additional_data=None): @@ -101,3 +105,30 @@ def obj_or_import_string(value, default=None): elif value: return value return default + + +def default_confirmation_link_func(user, token): + """Return the confirmation link that will be sent to a user via email.""" + return confirmation_link = url_for('.confirm_email', token=token, _external=True) + + +def register_user(confirmation_link_func=default_confirmation_link_func, + **user_data): + """Register a user.""" + user_data['password'] = hash_password(user_data['password']) + user = current_datastore.create_user(**user_data) + current_datastore.commit() + + token = None + if current_security.confirmable: + token = generate_confirmation_token(user) + confirmation_link = confirmation_link_func(user, token) + + user_registered.send( + current_app._get_current_object(), user=user, confirm_token=token) + + if security_config_value('SEND_REGISTER_EMAIL') + send_mail(security_config_value('EMAIL_SUBJECT_REGISTER'), user.email, + 'welcome', user=user, confirmation_link=confirmation_link) + + return user From b5616bd37b9502b91165eb85c509336e0ee102c3 Mon Sep 17 00:00:00 2001 From: Zacharias Zacharodimos Date: Fri, 6 Dec 2019 16:05:35 +0100 Subject: [PATCH 02/10] global: add rest auth views --- invenio_accounts/config.py | 41 +++ invenio_accounts/ext.py | 18 +- invenio_accounts/utils.py | 56 +++- invenio_accounts/views/__init__.py | 2 +- invenio_accounts/views/rest.py | 496 +++++++++++++++++++++++++++++ setup.py | 6 + tests/conftest.py | 26 +- tests/test_views_auth.py | 76 +++++ 8 files changed, 704 insertions(+), 17 deletions(-) create mode 100644 invenio_accounts/views/rest.py create mode 100644 tests/test_views_auth.py diff --git a/invenio_accounts/config.py b/invenio_accounts/config.py index 30b905f1..890c05d2 100644 --- a/invenio_accounts/config.py +++ b/invenio_accounts/config.py @@ -48,6 +48,47 @@ ACCOUNTS_SETTINGS_SECURITY_TEMPLATE = 'invenio_accounts/settings/security.html' """Template for the account security page.""" +ACCOUNTS_CONFIRM_EMAIL_ENDPOINT = None +"""Value to be used for the confirmation email link in the UI application.""" + +ACCOUNTS_REST_CONFIRM_EMAIL_ENDPOINT = '/confirm-email' +"""Value to be used for the confirmation email link in the API application. + +Can be a Flask endpoint (e.g. "invenio_accounts_rest_auth.confirm_email"), or +a URL part (e.g. "https://ui.example.com/confirm-email", "/confirm-email"). + +This will be used to build an absolute URL, thus if e.g. a hostname isn't +included, the one from the current request's context will be used. +""" + +ACCOUNTS_RESET_PASSWORD_ENDPOINT = None +"""Value to be used for the confirmation email link in the UI application.""" + +ACCOUNTS_REST_RESET_PASSWORD_ENDPOINT = '/reset-password' +"""Value to be used for the reset password link in the API application. + +Can be a Flask endpoint (e.g. "invenio_accounts_rest_auth.reset_password"), or +a URL part (e.g. "https://ui.example.com/reset-password", "/reset-password"). + +This will be used to build an absolute URL, thus if e.g. a hostname isn't +included, the one from the current request's context will be used. +""" + +ACCOUNTS_REST_AUTH_VIEWS = { + "login": "invenio_accounts.views.rest:LoginView", + "logout": "invenio_accounts.views.rest:LogoutView", + "user_info": "invenio_accounts.views.rest:UserInfoView", + "register": "invenio_accounts.views.rest:RegisterView", + "forgot_password": "invenio_accounts.views.rest:ForgotPasswordView", + "reset_password": "invenio_accounts.views.rest:ResetPasswordView", + "change_password": "invenio_accounts.views.rest:ChangePasswordView", + "send_confirmation": + "invenio_accounts.views.rest:SendConfirmationEmailView", + "confirm_email": "invenio_accounts.views.rest:ConfirmEmailView", + "revoke_session": "invenio_accounts.views.rest:RevokeSessionView" +} +"""List of REST API authentication views.""" + # Change Flask-Security defaults SECURITY_PASSWORD_HASH = 'pbkdf2_sha512' """Default password hashing algorithm for new passwords.""" diff --git a/invenio_accounts/ext.py b/invenio_accounts/ext.py index 9af12775..2c147b83 100644 --- a/invenio_accounts/ext.py +++ b/invenio_accounts/ext.py @@ -14,7 +14,7 @@ import pkg_resources import six -from flask import current_app, request_finished, session +from flask import Blueprint, abort, current_app, request_finished, session from flask_kvsession import KVSessionExtension from flask_login import LoginManager, user_logged_in, user_logged_out from flask_principal import AnonymousIdentity @@ -267,12 +267,24 @@ def init_app(self, app, sessionstore=None, register_blueprint=False): # Register the Flask-Security blueprint for the email templates if not register_blueprint: security_bp = Blueprint( - 'security', 'flask_security.core', template_folder='templates') + 'security_email_templates', # name differently to avoid misuse + 'flask_security.core', template_folder='templates') app.register_blueprint(security_bp) - return super(InvenioAccountsREST, self).init_app( + + super(InvenioAccountsREST, self).init_app( app, sessionstore=sessionstore, register_blueprint=register_blueprint, ) + app.config['ACCOUNTS_CONFIRM_EMAIL_ENDPOINT'] = \ + app.config['ACCOUNTS_REST_CONFIRM_EMAIL_ENDPOINT'] + app.config['ACCOUNTS_RESET_PASSWORD_ENDPOINT'] = \ + app.config['ACCOUNTS_REST_RESET_PASSWORD_ENDPOINT'] + + if app.config.get("ACCOUNTS_REGISTER_UNAUTHORIZED_CALLBACK", True): + def _unauthorized_callback(): + """Callback to abort when user is unauthorized.""" + abort(401) + app.login_manager.unauthorized_handler(_unauthorized_callback) class InvenioAccountsUI(InvenioAccounts): diff --git a/invenio_accounts/utils.py b/invenio_accounts/utils.py index ef634193..a1f47cf6 100644 --- a/invenio_accounts/utils.py +++ b/invenio_accounts/utils.py @@ -12,13 +12,18 @@ from datetime import datetime import six -from flask import current_app, session +from flask import current_app, request, session, url_for from flask_security import current_user from flask_security.confirmable import generate_confirmation_token +from flask_security.recoverable import generate_reset_password_token +from flask_security.signals import user_registered from flask_security.utils import config_value as security_config_value -from flask_security.utils import hash_password +from flask_security.utils import get_security_endpoint_name, hash_password, \ + send_mail from future.utils import raise_from from jwt import DecodeError, ExpiredSignatureError, decode, encode +from six.moves.urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit +from werkzeug.routing import BuildError from werkzeug.utils import import_string from .errors import JWTDecodeError, JWTExpiredToken @@ -107,28 +112,55 @@ def obj_or_import_string(value, default=None): return default -def default_confirmation_link_func(user, token): +def _generate_token_url(endpoint, token): + try: + url = url_for(endpoint, token=token, _external=True) + except BuildError: + # Try to parse URL and build + scheme, netloc, path, query, fragment = urlsplit(endpoint) + scheme = scheme or request.scheme + netloc = netloc or request.host + assert netloc + qs = parse_qs(query) + qs['token'] = token + query = urlencode(qs) + url = urlunsplit((scheme, netloc, path, query, fragment)) + return url + + +def default_reset_password_link_func(user): + """Return the confirmation link that will be sent to a user via email.""" + token = generate_reset_password_token(user) + endpoint = current_app.config['ACCOUNTS_RESET_PASSWORD_ENDPOINT'] or \ + get_security_endpoint_name('reset_password') + return token, _generate_token_url(endpoint, token) + + +def default_confirmation_link_func(user): """Return the confirmation link that will be sent to a user via email.""" - return confirmation_link = url_for('.confirm_email', token=token, _external=True) + token = generate_confirmation_token(user) + endpoint = current_app.config['ACCOUNTS_CONFIRM_EMAIL_ENDPOINT'] or \ + get_security_endpoint_name('confirm_email') + return token, _generate_token_url(endpoint, token) -def register_user(confirmation_link_func=default_confirmation_link_func, - **user_data): +def register_user(_confirmation_link_func=None, **user_data): """Register a user.""" + confirmation_link_func = _confirmation_link_func or \ + default_confirmation_link_func user_data['password'] = hash_password(user_data['password']) user = current_datastore.create_user(**user_data) current_datastore.commit() - token = None - if current_security.confirmable: - token = generate_confirmation_token(user) - confirmation_link = confirmation_link_func(user, token) + token, confirmation_link = None, None + if current_security.confirmable and user.confirmed_at is None: + token, confirmation_link = confirmation_link_func(user) user_registered.send( current_app._get_current_object(), user=user, confirm_token=token) - if security_config_value('SEND_REGISTER_EMAIL') + if security_config_value('SEND_REGISTER_EMAIL'): send_mail(security_config_value('EMAIL_SUBJECT_REGISTER'), user.email, - 'welcome', user=user, confirmation_link=confirmation_link) + 'welcome', user=user, confirmation_link=confirmation_link) return user diff --git a/invenio_accounts/views/__init__.py b/invenio_accounts/views/__init__.py index a07ddc55..c2e9ee67 100644 --- a/invenio_accounts/views/__init__.py +++ b/invenio_accounts/views/__init__.py @@ -10,4 +10,4 @@ from .settings import blueprint -__all__ = ('blueprint', ) +__all__ = ('blueprint') diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py new file mode 100644 index 00000000..fc1fadc1 --- /dev/null +++ b/invenio_accounts/views/rest.py @@ -0,0 +1,496 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2017-2019 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. + +"""REST API user management and authentication.""" + +from __future__ import absolute_import, print_function + +from flask import Blueprint, after_this_request, current_app, jsonify +from flask.views import MethodView +from flask_login import login_required +from flask_security import current_user +from flask_security.changeable import change_user_password +from flask_security.confirmable import confirm_email_token_status, \ + confirm_user, requires_confirmation +from flask_security.decorators import anonymous_user_required +from flask_security.recoverable import reset_password_token_status, \ + update_password +from flask_security.signals import reset_password_instructions_sent +from flask_security.utils import config_value, get_message, login_user, \ + logout_user, send_mail, verify_and_update_password +from invenio_db import db +from invenio_rest.errors import FieldError, RESTValidationError +from webargs import ValidationError, fields, validate +from webargs.flaskparser import FlaskParser as FlaskParserBase + +from invenio_accounts.models import SessionActivity +from invenio_accounts.sessions import delete_session + +from ..proxies import current_datastore, current_security +from ..utils import default_confirmation_link_func, \ + default_reset_password_link_func, obj_or_import_string, register_user + + +def role_to_dict(role): + """Serialize a new role to dict. + + :param role: a new role to serialize into dict. + :return: dict from role. + :rtype: dict. + """ + return dict( + id=role.id, + name=role.name, + description=role.description, + ) + + +def create_blueprint(app): + """Conditionally creates the blueprint.""" + blueprint = Blueprint('invenio_accounts_rest_auth', __name__) + + security_state = app.extensions['security'] + + if app.config['ACCOUNTS_REST_AUTH_VIEWS']: + # Resolve the view classes + authentication_views = { + k: obj_or_import_string(v) + for k, v in app.config.get('ACCOUNTS_REST_AUTH_VIEWS', {}).items() + } + + blueprint.add_url_rule( + '/login', + view_func=authentication_views['login'].as_view('login')) + blueprint.add_url_rule( + '/logout', + view_func=authentication_views['logout'].as_view('logout')) + blueprint.add_url_rule( + '/me', + view_func=authentication_views['user_info'].as_view('user_info')) + + if security_state.registerable: + blueprint.add_url_rule( + '/register', + view_func=authentication_views['register'].as_view('register')) + + if security_state.changeable: + blueprint.add_url_rule( + '/change-password', + view_func=authentication_views['change_password'].as_view( + 'change_password')) + + if security_state.recoverable: + blueprint.add_url_rule( + '/forgot-password', + view_func=authentication_views['forgot_password'].as_view( + 'forgot_password')) + blueprint.add_url_rule( + '/reset-password', + view_func=authentication_views['reset_password'].as_view( + 'reset_password')) + + if security_state.confirmable: + blueprint.add_url_rule( + '/send-confirmation-email', + view_func=authentication_views['send_confirmation'].as_view( + 'send_confirmation')) + + blueprint.add_url_rule( + '/confirm-email', + view_func=authentication_views['confirm_email'].as_view( + 'confirm_email')) + + # TODO: Check this + if app.config['ACCOUNTS_SESSION_ACTIVITY_ENABLED']: + blueprint.add_url_rule( + '/revoke-session', + view_func=authentication_views['revoke_session'].as_view( + 'revoke_session')) + return blueprint + + +class FlaskParser(FlaskParserBase): + """.""" + + # TODO: Add error codes to all messages (e.g. 'user-already-exists') + def handle_error(self, error, *args, **kwargs): + """.""" + if isinstance(error, ValidationError): + _errors = [] + for field, messages in error.messages.items(): + _errors.extend([FieldError(field, msg) for msg in messages]) + raise RESTValidationError(errors=_errors) + super(FlaskParser, self).handle_error(error, *args, **kwargs) + + +webargs_parser = FlaskParser() +use_args = webargs_parser.use_args +use_kwargs = webargs_parser.use_kwargs + + +# +# Field validators +# +def user_exists(email): + """Validate that a user exists.""" + if not current_datastore.get_user(email): + raise ValidationError(get_message('USER_DOES_NOT_EXIST')[0]) + + +def unique_user_email(email): + """Validate unique user email.""" + if current_datastore.get_user(email) is not None: + raise ValidationError( + get_message('EMAIL_ALREADY_ASSOCIATED', email=email)[0]) + + +def default_user_payload(user): + """.""" + return { + 'id': user.id, + 'email': user.email, + 'confirmed_at': + user.confirmed_at.isoformat() if user.confirmed_at else None, + 'last_login_at': + user.last_login_at.isoformat() if user.last_login_at else None, + # TODO: Check roles + 'roles': [role_to_dict(role) for role in user.roles], + } + + +def _abort(message, field=None, status=None): + if field: + raise RESTValidationError([FieldError(field, message)]) + raise RESTValidationError(description=message) + + +def _commit(response=None): + current_datastore.commit() + return response + + +class LoginView(MethodView): + """View to login a user.""" + + decorators = [anonymous_user_required] + + post_args = { + 'email': fields.Email(required=True, validate=[user_exists]), + 'password': fields.String( + required=True, validate=[validate.Length(min=6, max=128)]) + } + + def success_response(self, user): + """Return a successful login response.""" + return jsonify(default_user_payload(user)) + + def verify_login(self, user, password=None, **kwargs): + """Verify the login via password.""" + if not user.password: + _abort(get_message('PASSWORD_NOT_SET')[0], 'password') + if not verify_and_update_password(password, user): + _abort(get_message('INVALID_PASSWORD')[0], 'password') + if requires_confirmation(user): + _abort(get_message('CONFIRMATION_REQUIRED')[0]) + if not user.is_active: + _abort(get_message('DISABLED_ACCOUNT')[0]) + + def get_user(self, email=None, **kwargs): + """Retrieve a user by the provided arguments.""" + return current_datastore.get_user(email) + + def login_user(self, user): + """Perform any login actions.""" + return login_user(user) + + @use_kwargs(post_args) + def post(self, **kwargs): + """Verify and login a user.""" + user = self.get_user(**kwargs) + self.verify_login(user, **kwargs) + self.login_user(user) + return self.success_response(user) + + +class UserInfoView(MethodView): + """.""" + + decorators = [login_required] + + def response(self, user): + """.""" + return jsonify(default_user_payload(user)) + + def get(self): + """.""" + return self.response(current_user) + + +class LogoutView(MethodView): + """.""" + + def logout_user(self): + """.""" + if current_user.is_authenticated: + logout_user() + + def success_response(self): + """.""" + return jsonify({'message': 'User logged out.'}) + + def post(self): + """.""" + self.logout_user() + return self.success_response() + + +class RegisterView(MethodView): + """View to register a new user.""" + + decorators = [anonymous_user_required] + + post_args = { + 'email': fields.Email(required=True, validate=[unique_user_email]), + 'password': fields.String( + required=True, validate=[validate.Length(min=6, max=128)]) + } + + def login_user(self, user): + """.""" + if not current_security.confirmable or \ + current_security.login_without_confirmation: + after_this_request(_commit) + login_user(user) + + def success_response(self, user): + """.""" + return jsonify(default_user_payload(user)) + + @use_kwargs(post_args) + def post(self, **kwargs): + """.""" + user = register_user(**kwargs) + self.login_user(user) + return self.success_response(user) + + +class ForgotPasswordView(MethodView): + """.""" + + decorators = [anonymous_user_required] + + reset_password_link_func = default_reset_password_link_func + + post_args = { + 'email': fields.Email(required=True, validate=[user_exists]), + } + + def get_user(self, email=None, **kwargs): + """Retrieve a user by the provided arguments.""" + return current_datastore.get_user(email) + + def send_reset_password_instructions(self, user): + """.""" + token, reset_link = self.reset_password_link_func(user) + if config_value('SEND_PASSWORD_RESET_EMAIL'): + send_mail(config_value('EMAIL_SUBJECT_PASSWORD_RESET'), user.email, + 'reset_instructions', user=user, reset_link=reset_link) + reset_password_instructions_sent.send( + current_app._get_current_object(), user=user, token=token) + + def success_response(self, user): + """.""" + return jsonify({'message': get_message( + 'PASSWORD_RESET_REQUEST', email=user.email)[0]}) + + @use_kwargs(post_args) + def post(self, **kwargs): + """.""" + user = self.get_user(**kwargs) + self.send_reset_password_instructions(user) + return self.success_response(user) + + +class ResetPasswordView(MethodView): + """.""" + + decorators = [anonymous_user_required] + + post_args = { + 'token': fields.String(required=True), + 'password': fields.String( + required=True, validate=[validate.Length(min=6, max=128)]), + } + + def get_user(self, token=None, **kwargs): + """.""" + # Verify the token + expired, invalid, user = reset_password_token_status(token) + if invalid: + _abort(get_message('INVALID_RESET_PASSWORD_TOKEN')[0]) + if expired: + _abort(get_message( + 'PASSWORD_RESET_EXPIRED', + email=user.email, + within=current_security.reset_password_within)[0]) + return user + + def success_response(self, user): + """.""" + return jsonify({'message': get_message('PASSWORD_RESET')[0]}) + + @use_kwargs(post_args) + def post(self, **kwargs): + """.""" + user = self.get_user(**kwargs) + after_this_request(_commit) + update_password(user, kwargs['password']) + login_user(user) + return self.success_response(user) + + +class ChangePasswordView(MethodView): + """.""" + + decorators = [login_required] + + post_args = { + 'password': fields.String( + required=True, validate=[validate.Length(min=6, max=128)]), + 'new_password': fields.String( + required=True, validate=[validate.Length(min=6, max=128)]), + } + + def verify_password(self, password=None, new_password=None, **kwargs): + """.""" + if not verify_and_update_password(password, current_user): + _abort(get_message('INVALID_PASSWORD')[0], 'password') + if password.data == new_password: + _abort(get_message('PASSWORD_IS_THE_SAME')[0], 'password') + + def change_password(self, new_password=None, **kwargs): + """.""" + after_this_request(_commit) + change_user_password(current_user._get_current_object(), new_password) + + def success_response(self): + """.""" + return jsonify({'message': get_message('PASSWORD_CHANGE')[0]}) + + @use_kwargs(post_args) + def post(self, **kwargs): + """.""" + self.verify_password(**kwargs) + self.change_password(**kwargs) + return self.success_response() + + +class SendConfirmationEmailView(MethodView): + """View function which sends confirmation instructions.""" + + decorators = [login_required] + + confirmation_link_func = default_confirmation_link_func + + post_args = { + 'email': fields.Email(required=True, validate=[user_exists]), + } + + def get_user(self, email=None, **kwargs): + """Retrieve a user by the provided arguments.""" + return current_datastore.get_user(email) + + def verify(self, user): + """.""" + if user.confirmed_at is not None: + _abort(get_message('ALREADY_CONFIRMED')[0]) + + def send_confirmation_link(self, user): + """.""" + send_email_enabled = current_security.confirmable and \ + config_value('SEND_REGISTER_EMAIL') + if send_email_enabled: + token, confirmation_link = self.confirmation_link_func(user) + # TODO: check if there's another template for the confirmation link + send_mail( + config_value('EMAIL_SUBJECT_REGISTER'), user.email, + 'welcome', user=user, confirmation_link=confirmation_link) + return token + + def success_response(self, user): + """.""" + return jsonify({ + 'message': get_message('CONFIRMATION_REQUEST', email=user.email)[0] + }) + + @use_kwargs(post_args) + def post(self, **kwargs): + """.""" + user = self.get_user(**kwargs) + self.verify(user) + self.send_confirmation_link(user) + return self.success_response(user) + + +class ConfirmEmailView(MethodView): + """.""" + + def get_user(self, token=None, **kwargs): + """.""" + expired, invalid, user = confirm_email_token_status(token) + + if not user or invalid: + _abort(get_message('INVALID_CONFIRMATION_TOKEN')) + + already_confirmed = user is not None and user.confirmed_at is not None + if expired and not already_confirmed: + _abort(get_message( + 'CONFIRMATION_EXPIRED', + email=user.email, + within=current_security.confirm_email_within)) + return user + + @use_kwargs({'token': fields.String(required=True)}) + def post(self, **kwargs): + """View function which handles a email confirmation request.""" + user = self.get_user(**kwargs) + + if user != current_user: + logout_user() + + if confirm_user(user): + after_this_request(_commit) + return jsonify({'message': get_message('EMAIL_CONFIRMED')[0]}) + else: + return jsonify({'message': get_message('ALREADY_CONFIRMED')[0]}) + + +class RevokeSessionView(MethodView): + """.""" + + decorators = [login_required] + + post_args = { + 'sid_s': fields.String(required=True), + } + + @use_kwargs(post_args) + def post(self, sid_s=None, **kwargs): + """.""" + if SessionActivity.query.filter_by( + user_id=current_user.get_id(), sid_s=sid_s).count() == 1: + delete_session(sid_s=sid_s) + db.session.commit() + if not SessionActivity.is_current(sid_s=sid_s): + return jsonify({ + 'message': + 'Session {0} successfully removed.'.format(sid_s) + }) + else: + return jsonify({ + 'message': 'Unable to remove session {0}.'.format(sid_s)}), 400 diff --git a/setup.py b/setup.py index 6c4c5b72..06cd4903 100644 --- a/setup.py +++ b/setup.py @@ -76,6 +76,8 @@ 'invenio-base>=1.2.2', 'invenio-i18n>=1.2.0', 'invenio-celery>=1.1.2', + 'invenio-i18n>=1.0.0', + 'invenio-rest>=1.1.0', 'maxminddb-geolite2>=2017.404', 'passlib>=1.7.1', 'pyjwt>=1.5.0', @@ -129,6 +131,10 @@ 'invenio_base.blueprints': [ 'invenio_accounts = invenio_accounts.views.settings:blueprint', ], + 'invenio_base.api_blueprints': [ + 'invenio_accounts_rest_auth = ' + 'invenio_accounts.views.rest:create_blueprint', + ], 'invenio_celery.tasks': [ 'invenio_accounts = invenio_accounts.tasks', ], diff --git a/tests/conftest.py b/tests/conftest.py index 397dbdba..95a42dcd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,16 +23,19 @@ from flask_celeryext import FlaskCeleryExt from flask_mail import Mail from flask_menu import Menu +from invenio_access import InvenioAccess from invenio_db import InvenioDB, db from invenio_i18n import InvenioI18N +from invenio_rest import InvenioREST from simplekv.memory.redisstore import RedisStore from sqlalchemy_utils.functions import create_database, database_exists, \ drop_database -from invenio_accounts import InvenioAccounts +from invenio_accounts import InvenioAccounts, InvenioAccountsREST from invenio_accounts.admin import role_adminview, session_adminview, \ user_adminview from invenio_accounts.testutils import create_test_user +from invenio_accounts.views.rest import create_blueprint def _app_factory(config=None): @@ -109,6 +112,27 @@ def app(request): yield app +@pytest.yield_fixture() +def api(request): + """Flask application fixture.""" + api_app = _app_factory( + dict( + SQLALCHEMY_DATABASE_URI=os.environ.get( + 'SQLALCHEMY_DATABASE_URI', 'sqlite:///test.db'), + SERVER_NAME='localhost', + TESTING=True, + )) + + InvenioAccess(api_app) + InvenioREST(api_app) + InvenioAccountsREST(api_app) + api_app.register_blueprint(create_blueprint(api_app)) + + _database_setup(app, request) + + yield api_app + + @pytest.yield_fixture() def app_with_redis_url(request): """Flask application fixture with Invenio Accounts.""" diff --git a/tests/test_views_auth.py b/tests/test_views_auth.py new file mode 100644 index 00000000..d34736b5 --- /dev/null +++ b/tests/test_views_auth.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2017-2019 CERN. +# +# Invenio is free software; you can redistribute it +# and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# Invenio is distributed in the hope that it will be +# useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Invenio; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. + + +"""Test authentication REST API views.""" + +from __future__ import absolute_import, print_function + +import json + +import pytest +from flask import url_for +from invenio_accounts.models import User +from invenio_db import db + + +def assert_error_fields(res, expected): + errors = res.json['errors'] + for field, msg in expected: + assert any( + e['field'] == field and msg in e['message'].lower() + for e in errors), errors + + +def test_login_view(api): + app = api + + with app.app_context(): + with app.test_client() as client: + url = url_for('invenio_accounts_rest_auth.login') + # Missing fields + res = client.post(url) + assert res.status_code == 400 + assert_error_fields(res, ( + ('password', 'required'), + ('email', 'required'), + )) + + # Invalid fields + res = client.post( + url, json={'email': 'invalid-email', 'password': 'short'}) + assert res.status_code == 400 + assert_error_fields(res, ( + ('password', 'length'), + ('email', 'not a valid email'), + ('email', 'user does not exist'), # TODO: do we want this? + )) + + # Invalid credentials + res = client.post( + url, json={'email': 'not@exists.com', 'password': '123456'}) + assert res.status_code == 400 + assert_error_fields(res, ( + ('email', 'user does not exist'), + )) From fa997d54e13eab980b973c1083dce086302dfca1 Mon Sep 17 00:00:00 2001 From: Alexander Ioannidis Date: Tue, 17 Dec 2019 11:13:43 +0100 Subject: [PATCH 03/10] tests: auth views --- invenio_accounts/testutils.py | 2 +- tests/conftest.py | 14 ++- tests/test_invenio_accounts.py | 5 +- tests/test_views_auth.py | 76 ----------------- tests/test_views_rest.py | 151 +++++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 87 deletions(-) delete mode 100644 tests/test_views_auth.py create mode 100644 tests/test_views_rest.py diff --git a/invenio_accounts/testutils.py b/invenio_accounts/testutils.py index aea13f76..e699da86 100644 --- a/invenio_accounts/testutils.py +++ b/invenio_accounts/testutils.py @@ -43,7 +43,7 @@ def create_test_user(email, password='123456', **kwargs): :returns: A :class:`invenio_accounts.models.User` instance. """ assert flask.current_app.testing - hashed_password = hash_password(password) + hashed_password = hash_password(password) if password else None user = _datastore.create_user(email=email, password=hashed_password, **kwargs) _datastore.commit() diff --git a/tests/conftest.py b/tests/conftest.py index 95a42dcd..a3237303 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,6 @@ from flask_celeryext import FlaskCeleryExt from flask_mail import Mail from flask_menu import Menu -from invenio_access import InvenioAccess from invenio_db import InvenioDB, db from invenio_i18n import InvenioI18N from invenio_rest import InvenioREST @@ -90,7 +89,7 @@ def teardown(): return app -@pytest.yield_fixture() +@pytest.fixture() def base_app(request): """Flask application fixture.""" app = _app_factory() @@ -98,7 +97,7 @@ def base_app(request): yield app -@pytest.yield_fixture() +@pytest.fixture() def app(request): """Flask application fixture with Invenio Accounts.""" app = _app_factory() @@ -112,28 +111,27 @@ def app(request): yield app -@pytest.yield_fixture() +@pytest.fixture() def api(request): """Flask application fixture.""" api_app = _app_factory( dict( SQLALCHEMY_DATABASE_URI=os.environ.get( - 'SQLALCHEMY_DATABASE_URI', 'sqlite:///test.db'), + 'SQLALCHEMY_DATABASE_URI', 'sqlite:///test.db'), SERVER_NAME='localhost', TESTING=True, )) - InvenioAccess(api_app) InvenioREST(api_app) InvenioAccountsREST(api_app) api_app.register_blueprint(create_blueprint(api_app)) - _database_setup(app, request) + _database_setup(api_app, request) yield api_app -@pytest.yield_fixture() +@pytest.fixture() def app_with_redis_url(request): """Flask application fixture with Invenio Accounts.""" app = _app_factory(dict( diff --git a/tests/test_invenio_accounts.py b/tests/test_invenio_accounts.py index b8867d4c..e4f7855b 100644 --- a/tests/test_invenio_accounts.py +++ b/tests/test_invenio_accounts.py @@ -10,8 +10,6 @@ from __future__ import absolute_import, print_function -import os - import pytest import requests from flask import Flask @@ -61,6 +59,7 @@ def test_init_rest(): ext = InvenioAccountsREST(app) assert 'invenio-accounts' in app.extensions assert 'security' not in app.blueprints.keys() + assert 'security_email_templates' in app.blueprints.keys() app = Flask('testapp') Babel(app) @@ -72,6 +71,7 @@ def test_init_rest(): ext.init_app(app) assert 'invenio-accounts' in app.extensions assert 'security' not in app.blueprints.keys() + assert 'security_email_templates' in app.blueprints.keys() app = Flask('testapp') app.config['ACCOUNTS_REGISTER_BLUEPRINT'] = True @@ -84,6 +84,7 @@ def test_init_rest(): ext.init_app(app) assert 'invenio-accounts' in app.extensions assert 'security' in app.blueprints.keys() + assert 'security_email_templates' in app.blueprints.keys() def test_alembic(app): diff --git a/tests/test_views_auth.py b/tests/test_views_auth.py deleted file mode 100644 index d34736b5..00000000 --- a/tests/test_views_auth.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of Invenio. -# Copyright (C) 2017-2019 CERN. -# -# Invenio is free software; you can redistribute it -# and/or modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation; either version 2 of the -# License, or (at your option) any later version. -# -# Invenio is distributed in the hope that it will be -# useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Invenio; if not, write to the -# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, -# MA 02111-1307, USA. -# -# In applying this license, CERN does not -# waive the privileges and immunities granted to it by virtue of its status -# as an Intergovernmental Organization or submit itself to any jurisdiction. - - -"""Test authentication REST API views.""" - -from __future__ import absolute_import, print_function - -import json - -import pytest -from flask import url_for -from invenio_accounts.models import User -from invenio_db import db - - -def assert_error_fields(res, expected): - errors = res.json['errors'] - for field, msg in expected: - assert any( - e['field'] == field and msg in e['message'].lower() - for e in errors), errors - - -def test_login_view(api): - app = api - - with app.app_context(): - with app.test_client() as client: - url = url_for('invenio_accounts_rest_auth.login') - # Missing fields - res = client.post(url) - assert res.status_code == 400 - assert_error_fields(res, ( - ('password', 'required'), - ('email', 'required'), - )) - - # Invalid fields - res = client.post( - url, json={'email': 'invalid-email', 'password': 'short'}) - assert res.status_code == 400 - assert_error_fields(res, ( - ('password', 'length'), - ('email', 'not a valid email'), - ('email', 'user does not exist'), # TODO: do we want this? - )) - - # Invalid credentials - res = client.post( - url, json={'email': 'not@exists.com', 'password': '123456'}) - assert res.status_code == 400 - assert_error_fields(res, ( - ('email', 'user does not exist'), - )) diff --git a/tests/test_views_rest.py b/tests/test_views_rest.py new file mode 100644 index 00000000..b0b7eaed --- /dev/null +++ b/tests/test_views_rest.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2016-2018 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE + +"""Test authentication REST API views.""" + +from __future__ import absolute_import, print_function + +import json + +from flask import url_for +from invenio_db import db + +from invenio_accounts.testutils import create_test_user + + +def get_json(response): + """Get JSON from response.""" + return json.loads(response.get_data(as_text=True)) + + +def assert_error_resp(res, expected_errors, expected_status_code=400): + """Assert errors in a client response.""" + assert res.status_code == expected_status_code + payload = get_json(res) + errors = payload.get('errors', []) + for field, msg in expected_errors: + if not field: # top-level "message" error + assert msg in payload['message'].lower() + continue + assert any( + e['field'] == field and msg in e['message'].lower() + for e in errors), payload + + +def test_login_view(api): + app = api + with app.app_context(): + + normal_user = create_test_user(email='normal@test.com') + create_test_user(email='disabled@test.com', active=False) + create_test_user(email='nopass@test.com', password=None) + + db.session.commit() + + with app.test_client() as client: + url = url_for('invenio_accounts_rest_auth.login') + # Missing fields + res = client.post(url) + assert_error_resp(res, ( + ('password', 'required'), + ('email', 'required'), + )) + + # Invalid fields + res = client.post(url, data=json.dumps( + {'email': 'invalid-email', 'password': 'short'})) + assert_error_resp(res, ( + ('password', 'length'), + ('email', 'not a valid email'), + # TODO: probably shouldn't be here... + ('email', 'user does not exist'), + )) + + # Invalid credentials + res = client.post(url, data=json.dumps( + {'email': 'not@exists.com', 'password': '123456'})) + assert_error_resp(res, ( + ('email', 'user does not exist'), + )) + + # No-password user + res = client.post(url, data=json.dumps( + {'email': 'nopass@test.com', 'password': '123456'})) + assert_error_resp(res, ( + ('password', 'no password is set'), + )) + + # Disabled account + res = client.post(url, data=json.dumps( + {'email': 'disabled@test.com', 'password': '123456'})) + assert_error_resp(res, ( + (None, 'account is disabled'), + )) + + # Successful login + res = client.post(url, data=json.dumps( + {'email': 'normal@test.com', 'password': '123456'})) + payload = get_json(res) + assert res.status_code == 200 + assert payload['id'] == normal_user.id + assert payload['email'] == normal_user.email + session_cookie = next( + c for c in client.cookie_jar if c.name == 'session') + assert session_cookie is not None + assert session_cookie.value + + res = client.get(url_for('invenio_accounts_rest_auth.user_info')) + payload = get_json(res) + assert res.status_code == 200 + assert payload['id'] == normal_user.id + + +def test_registration_view(api): + app = api + with app.app_context(): + create_test_user(email='old@test.com') + db.session.commit() + with app.test_client() as client: + url = url_for('invenio_accounts_rest_auth.register') + + # Missing fields + res = client.post(url) + assert_error_resp(res, ( + ('password', 'required'), + ('email', 'required'), + )) + + # Invalid fields + res = client.post(url, data=json.dumps( + {'email': 'invalid-email', 'password': 'short'})) + assert_error_resp(res, ( + ('password', 'length'), + ('email', 'not a valid email'), + )) + + # Existing user + res = client.post(url, data=json.dumps( + {'email': 'old@test.com', 'password': '123456'})) + assert_error_resp(res, ( + ('email', 'old@test.com is already associated'), + )) + + # Successful registration + res = client.post(url, data=json.dumps( + {'email': 'new@test.com', 'password': '123456'})) + payload = get_json(res) + assert res.status_code == 200 + assert payload['id'] == 2 + assert payload['email'] == 'new@test.com' + session_cookie = next( + c for c in client.cookie_jar if c.name == 'session') + assert session_cookie is not None + assert session_cookie.value + + res = client.get(url_for('invenio_accounts_rest_auth.user_info')) + assert res.status_code == 200 From 1bc20143c80bad2d1bc375d4c7a7251b8e79165a Mon Sep 17 00:00:00 2001 From: Zacharias Zacharodimos Date: Thu, 16 Jan 2020 10:05:59 +0100 Subject: [PATCH 04/10] rest: return user if already authenticated --- invenio_accounts/views/rest.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py index fc1fadc1..f3313990 100644 --- a/invenio_accounts/views/rest.py +++ b/invenio_accounts/views/rest.py @@ -10,6 +10,8 @@ from __future__ import absolute_import, print_function +from functools import wraps + from flask import Blueprint, after_this_request, current_app, jsonify from flask.views import MethodView from flask_login import login_required @@ -17,7 +19,6 @@ from flask_security.changeable import change_user_password from flask_security.confirmable import confirm_email_token_status, \ confirm_user, requires_confirmation -from flask_security.decorators import anonymous_user_required from flask_security.recoverable import reset_password_token_status, \ update_password from flask_security.signals import reset_password_instructions_sent @@ -36,6 +37,16 @@ default_reset_password_link_func, obj_or_import_string, register_user +def user_already_authenticated(f): + """Return user if already authenticated.""" + @wraps(f) + def wrapper(*args, **kwargs): + if current_user.is_authenticated: + return jsonify(default_user_payload(current_user)) + return f(*args, **kwargs) + return wrapper + + def role_to_dict(role): """Serialize a new role to dict. @@ -177,7 +188,7 @@ def _commit(response=None): class LoginView(MethodView): """View to login a user.""" - decorators = [anonymous_user_required] + decorators = [user_already_authenticated] post_args = { 'email': fields.Email(required=True, validate=[user_exists]), @@ -252,7 +263,7 @@ def post(self): class RegisterView(MethodView): """View to register a new user.""" - decorators = [anonymous_user_required] + decorators = [user_already_authenticated] post_args = { 'email': fields.Email(required=True, validate=[unique_user_email]), @@ -282,7 +293,7 @@ def post(self, **kwargs): class ForgotPasswordView(MethodView): """.""" - decorators = [anonymous_user_required] + decorators = [user_already_authenticated] reset_password_link_func = default_reset_password_link_func @@ -319,7 +330,7 @@ def post(self, **kwargs): class ResetPasswordView(MethodView): """.""" - decorators = [anonymous_user_required] + decorators = [user_already_authenticated] post_args = { 'token': fields.String(required=True), From e12f9d179c7053d10d5932e303717ea5f46a40ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Vidal=20Garc=C3=ADa?= Date: Tue, 3 Mar 2020 16:05:08 +0100 Subject: [PATCH 05/10] rest: fix change password view --- docs/configuration.rst | 2 ++ invenio_accounts/ext.py | 4 ++++ .../security/email/change_notice_rest.html | 4 ++++ .../security/email/change_notice_rest.txt | 6 ++++++ invenio_accounts/utils.py | 20 ++++++++++++++++++- invenio_accounts/views/rest.py | 18 ++++++++--------- 6 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 invenio_accounts/templates/security/email/change_notice_rest.html create mode 100644 invenio_accounts/templates/security/email/change_notice_rest.txt diff --git a/docs/configuration.rst b/docs/configuration.rst index 1aa6821a..51b95f47 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -126,6 +126,8 @@ login, logout, email confirmations etc. Here are some few of the possiblities: .. autodata:: invenio_accounts.config.SECURITY_RESET_PASSWORD_TEMPLATE +.. autodata:: invenio_accounts.config.SECURITY_CHANGE_PASSWORD_TEMPLATE + .. autodata:: invenio_accounts.config.SECURITY_FORGOT_PASSWORD_TEMPLATE .. autodata:: invenio_accounts.config.SECURITY_SEND_CONFIRMATION_TEMPLATE diff --git a/invenio_accounts/ext.py b/invenio_accounts/ext.py index 2c147b83..30c9e67f 100644 --- a/invenio_accounts/ext.py +++ b/invenio_accounts/ext.py @@ -269,7 +269,11 @@ def init_app(self, app, sessionstore=None, register_blueprint=False): security_bp = Blueprint( 'security_email_templates', # name differently to avoid misuse 'flask_security.core', template_folder='templates') + security_rest_overrides = Blueprint( + 'security_email_overrides', # overrides + __name__, template_folder='templates') app.register_blueprint(security_bp) + app.register_blueprint(security_rest_overrides) super(InvenioAccountsREST, self).init_app( app, sessionstore=sessionstore, diff --git a/invenio_accounts/templates/security/email/change_notice_rest.html b/invenio_accounts/templates/security/email/change_notice_rest.html new file mode 100644 index 00000000..1d5095a8 --- /dev/null +++ b/invenio_accounts/templates/security/email/change_notice_rest.html @@ -0,0 +1,4 @@ +

{{ _('Your password has been changed.') }}

+{% if security.recoverable %} +

{{ _('If you did not change your password,') }} {{ _('click here to reset it') }}.

+{% endif %} diff --git a/invenio_accounts/templates/security/email/change_notice_rest.txt b/invenio_accounts/templates/security/email/change_notice_rest.txt new file mode 100644 index 00000000..26a99222 --- /dev/null +++ b/invenio_accounts/templates/security/email/change_notice_rest.txt @@ -0,0 +1,6 @@ +{{ _('Your password has been changed') }} +{% if security.recoverable %} +{{ _('If you did not change your password, click the link below to reset it.') }} +{{ reset_password_link }} +{% endif %} + diff --git a/invenio_accounts/utils.py b/invenio_accounts/utils.py index a1f47cf6..9de51a76 100644 --- a/invenio_accounts/utils.py +++ b/invenio_accounts/utils.py @@ -16,7 +16,7 @@ from flask_security import current_user from flask_security.confirmable import generate_confirmation_token from flask_security.recoverable import generate_reset_password_token -from flask_security.signals import user_registered +from flask_security.signals import password_changed, user_registered from flask_security.utils import config_value as security_config_value from flask_security.utils import get_security_endpoint_name, hash_password, \ send_mail @@ -164,3 +164,21 @@ def register_user(_confirmation_link_func=None, **user_data): 'welcome', user=user, confirmation_link=confirmation_link) return user + + +def change_user_password(_reset_password_link_func=None, **user_data): + """Change user password.""" + reset_password_link_func = _reset_password_link_func or \ + default_reset_password_link_func + user = user_data['user'] + user.password = hash_password(user_data['password']) + current_datastore.put(user) + if security_config_value('SEND_PASSWORD_CHANGE_EMAIL'): + reset_password_link = None + if current_security.recoverable: + _, reset_password_link = reset_password_link_func(user) + subject = security_config_value('EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE') + send_mail(subject, user.email, 'change_notice_rest', user=user, + reset_password_link=reset_password_link) + password_changed.send(current_app._get_current_object(), + user=user) diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py index f3313990..320a119f 100644 --- a/invenio_accounts/views/rest.py +++ b/invenio_accounts/views/rest.py @@ -16,7 +16,6 @@ from flask.views import MethodView from flask_login import login_required from flask_security import current_user -from flask_security.changeable import change_user_password from flask_security.confirmable import confirm_email_token_status, \ confirm_user, requires_confirmation from flask_security.recoverable import reset_password_token_status, \ @@ -33,7 +32,7 @@ from invenio_accounts.sessions import delete_session from ..proxies import current_datastore, current_security -from ..utils import default_confirmation_link_func, \ +from ..utils import change_user_password, default_confirmation_link_func, \ default_reset_password_link_func, obj_or_import_string, register_user @@ -366,7 +365,7 @@ def post(self, **kwargs): class ChangePasswordView(MethodView): - """.""" + """View to change the user password.""" decorators = [login_required] @@ -378,24 +377,25 @@ class ChangePasswordView(MethodView): } def verify_password(self, password=None, new_password=None, **kwargs): - """.""" + """Verify password is not invalid.""" if not verify_and_update_password(password, current_user): _abort(get_message('INVALID_PASSWORD')[0], 'password') - if password.data == new_password: + if password == new_password: _abort(get_message('PASSWORD_IS_THE_SAME')[0], 'password') def change_password(self, new_password=None, **kwargs): - """.""" + """Perform any change password actions.""" after_this_request(_commit) - change_user_password(current_user._get_current_object(), new_password) + change_user_password(user=current_user._get_current_object(), + password=new_password) def success_response(self): - """.""" + """Return a successful change password response.""" return jsonify({'message': get_message('PASSWORD_CHANGE')[0]}) @use_kwargs(post_args) def post(self, **kwargs): - """.""" + """Change user password.""" self.verify_password(**kwargs) self.change_password(**kwargs) return self.success_response() From c3f63e9a88f1e20cd177e80868e4776329a0e8aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Vidal=20Garc=C3=ADa?= Date: Tue, 3 Mar 2020 16:07:17 +0100 Subject: [PATCH 06/10] rest: add docstrings and fix some issues --- invenio_accounts/config.py | 4 +- invenio_accounts/utils.py | 5 ++- invenio_accounts/views/rest.py | 74 ++++++++++++++++++---------------- 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/invenio_accounts/config.py b/invenio_accounts/config.py index 890c05d2..3e326c4f 100644 --- a/invenio_accounts/config.py +++ b/invenio_accounts/config.py @@ -51,7 +51,7 @@ ACCOUNTS_CONFIRM_EMAIL_ENDPOINT = None """Value to be used for the confirmation email link in the UI application.""" -ACCOUNTS_REST_CONFIRM_EMAIL_ENDPOINT = '/confirm-email' +ACCOUNTS_REST_CONFIRM_EMAIL_ENDPOINT = '/confirm/{token}' """Value to be used for the confirmation email link in the API application. Can be a Flask endpoint (e.g. "invenio_accounts_rest_auth.confirm_email"), or @@ -64,7 +64,7 @@ ACCOUNTS_RESET_PASSWORD_ENDPOINT = None """Value to be used for the confirmation email link in the UI application.""" -ACCOUNTS_REST_RESET_PASSWORD_ENDPOINT = '/reset-password' +ACCOUNTS_REST_RESET_PASSWORD_ENDPOINT = '/lost-password/{token}' """Value to be used for the reset password link in the API application. Can be a Flask endpoint (e.g. "invenio_accounts_rest_auth.reset_password"), or diff --git a/invenio_accounts/utils.py b/invenio_accounts/utils.py index 9de51a76..15da531e 100644 --- a/invenio_accounts/utils.py +++ b/invenio_accounts/utils.py @@ -122,7 +122,10 @@ def _generate_token_url(endpoint, token): netloc = netloc or request.host assert netloc qs = parse_qs(query) - qs['token'] = token + if '{token}' in path: + path = path.format(token=token) + else: + qs['token'] = token query = urlencode(qs) url = urlunsplit((scheme, netloc, path, query, fragment)) return url diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py index 320a119f..831bb65c 100644 --- a/invenio_accounts/views/rest.py +++ b/invenio_accounts/views/rest.py @@ -228,33 +228,33 @@ def post(self, **kwargs): class UserInfoView(MethodView): - """.""" + """View to fetch info from current user.""" decorators = [login_required] - def response(self, user): - """.""" + def success_response(self, user): + """Return a successful user info response.""" return jsonify(default_user_payload(user)) def get(self): - """.""" - return self.response(current_user) + """Return user info.""" + return self.success_response(current_user) class LogoutView(MethodView): - """.""" + """View to logout a user.""" def logout_user(self): - """.""" + """Perform any logout actions.""" if current_user.is_authenticated: logout_user() def success_response(self): - """.""" + """Return a successful logout response.""" return jsonify({'message': 'User logged out.'}) def post(self): - """.""" + """Logout a user.""" self.logout_user() return self.success_response() @@ -271,26 +271,26 @@ class RegisterView(MethodView): } def login_user(self, user): - """.""" + """Perform any login actions.""" if not current_security.confirmable or \ current_security.login_without_confirmation: after_this_request(_commit) login_user(user) def success_response(self, user): - """.""" + """Return a successful register response.""" return jsonify(default_user_payload(user)) @use_kwargs(post_args) def post(self, **kwargs): - """.""" + """Register a user.""" user = register_user(**kwargs) self.login_user(user) return self.success_response(user) class ForgotPasswordView(MethodView): - """.""" + """View to get a link to reset the user password.""" decorators = [user_already_authenticated] @@ -304,9 +304,10 @@ def get_user(self, email=None, **kwargs): """Retrieve a user by the provided arguments.""" return current_datastore.get_user(email) - def send_reset_password_instructions(self, user): - """.""" - token, reset_link = self.reset_password_link_func(user) + @classmethod + def send_reset_password_instructions(cls, user): + """Send email containing instructions to reset password.""" + token, reset_link = cls.reset_password_link_func(user) if config_value('SEND_PASSWORD_RESET_EMAIL'): send_mail(config_value('EMAIL_SUBJECT_PASSWORD_RESET'), user.email, 'reset_instructions', user=user, reset_link=reset_link) @@ -314,20 +315,20 @@ def send_reset_password_instructions(self, user): current_app._get_current_object(), user=user, token=token) def success_response(self, user): - """.""" + """Return a response containing reset password instructions.""" return jsonify({'message': get_message( 'PASSWORD_RESET_REQUEST', email=user.email)[0]}) @use_kwargs(post_args) def post(self, **kwargs): - """.""" + """Send reset password instructions.""" user = self.get_user(**kwargs) self.send_reset_password_instructions(user) return self.success_response(user) class ResetPasswordView(MethodView): - """.""" + """View to reset the user password.""" decorators = [user_already_authenticated] @@ -338,7 +339,7 @@ class ResetPasswordView(MethodView): } def get_user(self, token=None, **kwargs): - """.""" + """Retrieve a user by the provided arguments.""" # Verify the token expired, invalid, user = reset_password_token_status(token) if invalid: @@ -351,12 +352,12 @@ def get_user(self, token=None, **kwargs): return user def success_response(self, user): - """.""" + """Return a successful reset password response.""" return jsonify({'message': get_message('PASSWORD_RESET')[0]}) @use_kwargs(post_args) def post(self, **kwargs): - """.""" + """Reset user password.""" user = self.get_user(**kwargs) after_this_request(_commit) update_password(user, kwargs['password']) @@ -417,31 +418,32 @@ def get_user(self, email=None, **kwargs): return current_datastore.get_user(email) def verify(self, user): - """.""" + """Verify that user is not confirmed.""" if user.confirmed_at is not None: _abort(get_message('ALREADY_CONFIRMED')[0]) - def send_confirmation_link(self, user): - """.""" + @classmethod + def send_confirmation_link(cls, user): + """Send confirmation link.""" send_email_enabled = current_security.confirmable and \ config_value('SEND_REGISTER_EMAIL') if send_email_enabled: - token, confirmation_link = self.confirmation_link_func(user) - # TODO: check if there's another template for the confirmation link + token, confirmation_link = cls.confirmation_link_func(user) send_mail( config_value('EMAIL_SUBJECT_REGISTER'), user.email, - 'welcome', user=user, confirmation_link=confirmation_link) + 'confirmation_instructions', user=user, + confirmation_link=confirmation_link) return token def success_response(self, user): - """.""" + """Return a successful confirmation email sent response.""" return jsonify({ 'message': get_message('CONFIRMATION_REQUEST', email=user.email)[0] }) @use_kwargs(post_args) def post(self, **kwargs): - """.""" + """Send confirmation email.""" user = self.get_user(**kwargs) self.verify(user) self.send_confirmation_link(user) @@ -449,10 +451,14 @@ def post(self, **kwargs): class ConfirmEmailView(MethodView): - """.""" + """View that handles a email confirmation request.""" + + post_args = { + 'token': fields.String(required=True), + } def get_user(self, token=None, **kwargs): - """.""" + """Retrieve a user by the provided arguments.""" expired, invalid, user = confirm_email_token_status(token) if not user or invalid: @@ -466,9 +472,9 @@ def get_user(self, token=None, **kwargs): within=current_security.confirm_email_within)) return user - @use_kwargs({'token': fields.String(required=True)}) + @use_kwargs(post_args) def post(self, **kwargs): - """View function which handles a email confirmation request.""" + """Confirm user email.""" user = self.get_user(**kwargs) if user != current_user: From 2454ef563222e477ba62311fe53ce5a1ec4f479a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Vidal=20Garc=C3=ADa?= Date: Tue, 3 Mar 2020 17:46:07 +0100 Subject: [PATCH 07/10] rest: add sessions list view * Structures better single session operations (e.g. revoke) --- invenio_accounts/config.py | 3 +- invenio_accounts/views/rest.py | 55 ++++++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/invenio_accounts/config.py b/invenio_accounts/config.py index 3e326c4f..976378c0 100644 --- a/invenio_accounts/config.py +++ b/invenio_accounts/config.py @@ -85,7 +85,8 @@ "send_confirmation": "invenio_accounts.views.rest:SendConfirmationEmailView", "confirm_email": "invenio_accounts.views.rest:ConfirmEmailView", - "revoke_session": "invenio_accounts.views.rest:RevokeSessionView" + "sessions_list": "invenio_accounts.views.rest:SessionsListView", + "sessions_item": "invenio_accounts.views.rest:SessionsItemView" } """List of REST API authentication views.""" diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py index 831bb65c..c5075f75 100644 --- a/invenio_accounts/views/rest.py +++ b/invenio_accounts/views/rest.py @@ -118,9 +118,14 @@ def create_blueprint(app): # TODO: Check this if app.config['ACCOUNTS_SESSION_ACTIVITY_ENABLED']: blueprint.add_url_rule( - '/revoke-session', - view_func=authentication_views['revoke_session'].as_view( - 'revoke_session')) + '/sessions', + view_func=authentication_views['sessions_list'].as_view( + 'sessions_list')) + + blueprint.add_url_rule( + '/sessions/', + view_func=authentication_views['sessions_item'].as_view( + 'sessions_item')) return blueprint @@ -487,27 +492,45 @@ def post(self, **kwargs): return jsonify({'message': get_message('ALREADY_CONFIRMED')[0]}) -class RevokeSessionView(MethodView): - """.""" +class SessionsListView(MethodView): + """View that returns the list of user sessions.""" decorators = [login_required] - post_args = { - 'sid_s': fields.String(required=True), - } + def get(self, sid_s=None, **kwargs): + """Return user sessions info.""" + sessions = SessionActivity.query.filter_by( + user_id=current_user.get_id()) + results = [{ + 'created': s.created, + 'current': s.is_current(s.sid_s), + 'browser': s.browser, + 'browser_version': s.browser_version, + 'os': s.os, + 'device': s.device, + 'country': s.country} for s in sessions] - @use_kwargs(post_args) - def post(self, sid_s=None, **kwargs): - """.""" + return jsonify({'total': sessions.count(), 'results': results}) + + +class SessionsItemView(MethodView): + """View for operations related to user session.""" + + decorators = [login_required] + + def delete(self, sid_s=None, **kwargs): + """Revoke the given user session.""" if SessionActivity.query.filter_by( user_id=current_user.get_id(), sid_s=sid_s).count() == 1: + import wdb; wdb.set_trace() delete_session(sid_s=sid_s) db.session.commit() - if not SessionActivity.is_current(sid_s=sid_s): - return jsonify({ - 'message': - 'Session {0} successfully removed.'.format(sid_s) - }) + message = 'Session {0} successfully removed. {1}.' + if SessionActivity.is_current(sid_s=sid_s): + message = message.format(sid_s, "Logged out") + else: + message = message.format(sid_s, "Revoked") + return jsonify({'message': message}) else: return jsonify({ 'message': 'Unable to remove session {0}.'.format(sid_s)}), 400 From d06fd761efa20702ba3addd9e6580e2b56e88046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Vidal=20Garc=C3=ADa?= Date: Wed, 4 Mar 2020 10:30:20 +0100 Subject: [PATCH 08/10] tests: fix current tests --- MANIFEST.in | 1 + invenio_accounts/views/rest.py | 1 - tests/test_views_rest.py | 35 +++++++++++++++++----------------- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index b05c8d25..955990a7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -30,6 +30,7 @@ recursive-include examples *.py recursive-include examples *.sh recursive-include examples *.txt recursive-include invenio_accounts *.html +recursive-include invenio_accounts *.txt recursive-include invenio_accounts *.po *.pot *.mo recursive-include invenio_accounts *.py recursive-include tests *.py diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py index c5075f75..a7db5951 100644 --- a/invenio_accounts/views/rest.py +++ b/invenio_accounts/views/rest.py @@ -522,7 +522,6 @@ def delete(self, sid_s=None, **kwargs): """Revoke the given user session.""" if SessionActivity.query.filter_by( user_id=current_user.get_id(), sid_s=sid_s).count() == 1: - import wdb; wdb.set_trace() delete_session(sid_s=sid_s) db.session.commit() message = 'Session {0} successfully removed. {1}.' diff --git a/tests/test_views_rest.py b/tests/test_views_rest.py index b0b7eaed..7cdc1873 100644 --- a/tests/test_views_rest.py +++ b/tests/test_views_rest.py @@ -57,39 +57,37 @@ def test_login_view(api): )) # Invalid fields - res = client.post(url, data=json.dumps( - {'email': 'invalid-email', 'password': 'short'})) + res = client.post(url, data=dict( + email='invalid-email', password='short')) assert_error_resp(res, ( ('password', 'length'), ('email', 'not a valid email'), - # TODO: probably shouldn't be here... - ('email', 'user does not exist'), )) # Invalid credentials - res = client.post(url, data=json.dumps( - {'email': 'not@exists.com', 'password': '123456'})) + res = client.post(url, data=dict( + email='not@exists.com', password='123456')) assert_error_resp(res, ( ('email', 'user does not exist'), )) # No-password user - res = client.post(url, data=json.dumps( - {'email': 'nopass@test.com', 'password': '123456'})) + res = client.post(url, data=dict( + email='nopass@test.com', password='123456')) assert_error_resp(res, ( ('password', 'no password is set'), )) # Disabled account - res = client.post(url, data=json.dumps( - {'email': 'disabled@test.com', 'password': '123456'})) + res = client.post(url, data=dict( + email='disabled@test.com', password='123456')) assert_error_resp(res, ( (None, 'account is disabled'), )) # Successful login - res = client.post(url, data=json.dumps( - {'email': 'normal@test.com', 'password': '123456'})) + res = client.post(url, data=dict( + email='normal@test.com', password='123456')) payload = get_json(res) assert res.status_code == 200 assert payload['id'] == normal_user.id @@ -99,6 +97,7 @@ def test_login_view(api): assert session_cookie is not None assert session_cookie.value + # User info view res = client.get(url_for('invenio_accounts_rest_auth.user_info')) payload = get_json(res) assert res.status_code == 200 @@ -121,23 +120,23 @@ def test_registration_view(api): )) # Invalid fields - res = client.post(url, data=json.dumps( - {'email': 'invalid-email', 'password': 'short'})) + res = client.post(url, data=dict( + email='invalid-email', password='short')) assert_error_resp(res, ( ('password', 'length'), ('email', 'not a valid email'), )) # Existing user - res = client.post(url, data=json.dumps( - {'email': 'old@test.com', 'password': '123456'})) + res = client.post(url, data=dict( + email='old@test.com', password='123456')) assert_error_resp(res, ( ('email', 'old@test.com is already associated'), )) # Successful registration - res = client.post(url, data=json.dumps( - {'email': 'new@test.com', 'password': '123456'})) + res = client.post(url, data=dict( + email='new@test.com', password='123456')) payload = get_json(res) assert res.status_code == 200 assert payload['id'] == 2 From 9ce232311a8a0636ddba329e1359cb9bb0814cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Vidal=20Garc=C3=ADa?= Date: Wed, 4 Mar 2020 16:37:03 +0100 Subject: [PATCH 09/10] tests: rest auth views --- examples/app-setup.sh | 6 - invenio_accounts/utils.py | 2 +- invenio_accounts/views/rest.py | 44 +++--- setup.py | 3 +- tests/test_examples_app.py | 2 +- tests/test_views_rest.py | 276 +++++++++++++++++++++++++++++++-- 6 files changed, 288 insertions(+), 45 deletions(-) diff --git a/examples/app-setup.sh b/examples/app-setup.sh index 411ba1d2..424d4bdf 100755 --- a/examples/app-setup.sh +++ b/examples/app-setup.sh @@ -18,13 +18,7 @@ mkdir static # Install specific dependencies pip install -r requirements.txt -npm install -g node-sass@3.8.0 clean-css@3.4.19 requirejs@2.2.0 uglify-js@2.7.3 -# Install assets -flask npm -cd static -npm install -cd .. flask collect flask webpack buildall diff --git a/invenio_accounts/utils.py b/invenio_accounts/utils.py index 15da531e..c54f91f3 100644 --- a/invenio_accounts/utils.py +++ b/invenio_accounts/utils.py @@ -132,7 +132,7 @@ def _generate_token_url(endpoint, token): def default_reset_password_link_func(user): - """Return the confirmation link that will be sent to a user via email.""" + """Return the reset password link that will be sent to a user via email.""" token = generate_reset_password_token(user) endpoint = current_app.config['ACCOUNTS_RESET_PASSWORD_ENDPOINT'] or \ get_security_endpoint_name('reset_password') diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py index a7db5951..e026d55d 100644 --- a/invenio_accounts/views/rest.py +++ b/invenio_accounts/views/rest.py @@ -115,7 +115,6 @@ def create_blueprint(app): view_func=authentication_views['confirm_email'].as_view( 'confirm_email')) - # TODO: Check this if app.config['ACCOUNTS_SESSION_ACTIVITY_ENABLED']: blueprint.add_url_rule( '/sessions', @@ -130,11 +129,11 @@ def create_blueprint(app): class FlaskParser(FlaskParserBase): - """.""" + """Parser to add FieldError to validation errors.""" # TODO: Add error codes to all messages (e.g. 'user-already-exists') def handle_error(self, error, *args, **kwargs): - """.""" + """Handle errors during parsing.""" if isinstance(error, ValidationError): _errors = [] for field, messages in error.messages.items(): @@ -165,7 +164,7 @@ def unique_user_email(email): def default_user_payload(user): - """.""" + """Parse user payload.""" return { 'id': user.id, 'email': user.email, @@ -173,7 +172,6 @@ def default_user_payload(user): user.confirmed_at.isoformat() if user.confirmed_at else None, 'last_login_at': user.last_login_at.isoformat() if user.last_login_at else None, - # TODO: Check roles 'roles': [role_to_dict(role) for role in user.roles], } @@ -189,7 +187,15 @@ def _commit(response=None): return response -class LoginView(MethodView): +class UserViewMixin(object): + """Mixin class for get user operations.""" + + def get_user(self, email=None, **kwargs): + """Retrieve a user by the provided arguments.""" + return current_datastore.get_user(email) + + +class LoginView(MethodView, UserViewMixin): """View to login a user.""" decorators = [user_already_authenticated] @@ -215,10 +221,6 @@ def verify_login(self, user, password=None, **kwargs): if not user.is_active: _abort(get_message('DISABLED_ACCOUNT')[0]) - def get_user(self, email=None, **kwargs): - """Retrieve a user by the provided arguments.""" - return current_datastore.get_user(email) - def login_user(self, user): """Perform any login actions.""" return login_user(user) @@ -294,7 +296,7 @@ def post(self, **kwargs): return self.success_response(user) -class ForgotPasswordView(MethodView): +class ForgotPasswordView(MethodView, UserViewMixin): """View to get a link to reset the user password.""" decorators = [user_already_authenticated] @@ -305,10 +307,6 @@ class ForgotPasswordView(MethodView): 'email': fields.Email(required=True, validate=[user_exists]), } - def get_user(self, email=None, **kwargs): - """Retrieve a user by the provided arguments.""" - return current_datastore.get_user(email) - @classmethod def send_reset_password_instructions(cls, user): """Send email containing instructions to reset password.""" @@ -407,7 +405,7 @@ def post(self, **kwargs): return self.success_response() -class SendConfirmationEmailView(MethodView): +class SendConfirmationEmailView(MethodView, UserViewMixin): """View function which sends confirmation instructions.""" decorators = [login_required] @@ -418,10 +416,6 @@ class SendConfirmationEmailView(MethodView): 'email': fields.Email(required=True, validate=[user_exists]), } - def get_user(self, email=None, **kwargs): - """Retrieve a user by the provided arguments.""" - return current_datastore.get_user(email) - def verify(self, user): """Verify that user is not confirmed.""" if user.confirmed_at is not None: @@ -497,13 +491,13 @@ class SessionsListView(MethodView): decorators = [login_required] - def get(self, sid_s=None, **kwargs): + def get(self, **kwargs): """Return user sessions info.""" - sessions = SessionActivity.query.filter_by( + sessions = SessionActivity.query_by_user( user_id=current_user.get_id()) results = [{ 'created': s.created, - 'current': s.is_current(s.sid_s), + 'current': SessionActivity.is_current(s.sid_s), 'browser': s.browser, 'browser_version': s.browser_version, 'os': s.os, @@ -520,8 +514,8 @@ class SessionsItemView(MethodView): def delete(self, sid_s=None, **kwargs): """Revoke the given user session.""" - if SessionActivity.query.filter_by( - user_id=current_user.get_id(), sid_s=sid_s).count() == 1: + if SessionActivity.query_by_user(current_user.get_id()) \ + .filter_by(sid_s=sid_s).count() == 1: delete_session(sid_s=sid_s) db.session.commit() message = 'Session {0} successfully removed. {1}.' diff --git a/setup.py b/setup.py index 06cd4903..1ace269f 100644 --- a/setup.py +++ b/setup.py @@ -76,8 +76,7 @@ 'invenio-base>=1.2.2', 'invenio-i18n>=1.2.0', 'invenio-celery>=1.1.2', - 'invenio-i18n>=1.0.0', - 'invenio-rest>=1.1.0', + 'invenio-rest>=1.1.3', 'maxminddb-geolite2>=2017.404', 'passlib>=1.7.1', 'pyjwt>=1.5.0', diff --git a/tests/test_examples_app.py b/tests/test_examples_app.py index fedf5401..d28b51c5 100644 --- a/tests/test_examples_app.py +++ b/tests/test_examples_app.py @@ -17,7 +17,7 @@ import pytest -@pytest.yield_fixture +@pytest.fixture def example_app(): """Example app fixture.""" current_dir = os.getcwd() diff --git a/tests/test_views_rest.py b/tests/test_views_rest.py index 7cdc1873..66efbb87 100644 --- a/tests/test_views_rest.py +++ b/tests/test_views_rest.py @@ -10,9 +10,12 @@ from __future__ import absolute_import, print_function +import datetime import json +import mock from flask import url_for +from flask_security import current_user from invenio_db import db from invenio_accounts.testutils import create_test_user @@ -37,6 +40,36 @@ def assert_error_resp(res, expected_errors, expected_status_code=400): for e in errors), payload +def _mock_send_mail(subject, recipient, template, **context): + from invenio_accounts.config import SECURITY_RESET_URL + from six.moves.urllib.parse import urlsplit + + assert context['reset_link'] + assert SECURITY_RESET_URL in urlsplit(context['reset_link'])[2] + + +def _mock_send_confirmation_mail(subject, recipient, template, **context): + from invenio_accounts.config import ACCOUNTS_REST_CONFIRM_EMAIL_ENDPOINT + + assert context['confirmation_link'] + assert ACCOUNTS_REST_CONFIRM_EMAIL_ENDPOINT.format(token='') in \ + context['confirmation_link'] + + +def _login_user(client, user, email='normal@test.com', password='123456'): + url = url_for('invenio_accounts_rest_auth.login') + res = client.post(url, data=dict(email=email, password=password)) + payload = get_json(res) + assert res.status_code == 200 + assert payload['id'] == user.id + assert payload['email'] == user.email + session_cookie = next( + c for c in client.cookie_jar if c.name == 'session') + assert session_cookie is not None + assert session_cookie.value + assert current_user.is_authenticated + + def test_login_view(api): app = api with app.app_context(): @@ -86,16 +119,7 @@ def test_login_view(api): )) # Successful login - res = client.post(url, data=dict( - email='normal@test.com', password='123456')) - payload = get_json(res) - assert res.status_code == 200 - assert payload['id'] == normal_user.id - assert payload['email'] == normal_user.email - session_cookie = next( - c for c in client.cookie_jar if c.name == 'session') - assert session_cookie is not None - assert session_cookie.value + _login_user(client, normal_user) # User info view res = client.get(url_for('invenio_accounts_rest_auth.user_info')) @@ -148,3 +172,235 @@ def test_registration_view(api): res = client.get(url_for('invenio_accounts_rest_auth.user_info')) assert res.status_code == 200 + + +def test_logout_view(api): + app = api + with app.app_context(): + normal_user = create_test_user(email='normal@test.com') + db.session.commit() + with app.test_client() as client: + + # Login user + _login_user(client, normal_user) + old_session_cookie = next( + c for c in client.cookie_jar if c.name == 'session') + + # Log out user + url = url_for('invenio_accounts_rest_auth.logout') + res = client.post(url) + payload = get_json(res) + assert payload['message'] == 'User logged out.' + new_session_cookie = next( + c for c in client.cookie_jar if c.name == 'session') + assert old_session_cookie.value != new_session_cookie.value + assert current_user.is_anonymous + + +@mock.patch('invenio_accounts.views.rest.send_mail', _mock_send_mail) +def test_forgot_password_view(api): + app = api + with app.app_context(): + normal_user = create_test_user(email='normal@test.com') + db.session.commit() + with app.test_client() as client: + url = url_for('invenio_accounts_rest_auth.forgot_password') + + # Invalid email + res = client.post(url, data=dict(email='invalid')) + assert_error_resp(res, ( + ('email', 'user does not exist'), + )) + + # Valid email + res = client.post(url, data=dict(email='normal@test.com')) + payload = get_json(res) + assert 'Instructions to reset your password' in payload['message'] + + +def test_reset_password_view(api): + from flask_security.recoverable import generate_reset_password_token + app = api + with app.app_context(): + normal_user = create_test_user(email='normal@test.com') + # Generate token + token = generate_reset_password_token(normal_user) + db.session.commit() + with app.test_client() as client: + url = url_for('invenio_accounts_rest_auth.reset_password') + res = client.post(url, data=dict(password='new-password', + token=token)) + payload = get_json(res) + assert res.status_code == 200 + assert 'You successfully reset your password' in payload['message'] + + # Login using new password + res = client.post(url, data=dict( + email='normal@test.com', password='new-password')) + payload = get_json(res) + assert res.status_code == 200 + assert payload['id'] == normal_user.id + assert payload['email'] == normal_user.email + session_cookie = next( + c for c in client.cookie_jar if c.name == 'session') + assert session_cookie is not None + assert session_cookie.value + + +def test_change_password_view(api): + app = api + with app.app_context(): + normal_user = create_test_user(email='normal@test.com') + db.session.commit() + with app.test_client() as client: + url = url_for('invenio_accounts_rest_auth.change_password') + + # Not authorized user + res = client.post(url, data=dict(password='123456', + new_password='new-password')) + assert_error_resp(res, ( + (None, 'server could not verify that you are authorized'), + ), expected_status_code=401) + + # Logged in user + _login_user(client, normal_user) + + # Same password + res = client.post(url, data=dict(password='123456', + new_password='123456')) + assert_error_resp(res, ( + ('password', 'new password must be different'), + )) + + # Valid password + res = client.post(url, data=dict(password='123456', + new_password='new-password')) + payload = get_json(res) + assert 'successfully changed your password' in payload['message'] + # Log in using new password + _login_user(client, normal_user, password='new-password') + + +@mock.patch('invenio_accounts.views.rest.send_mail', + _mock_send_confirmation_mail) +def test_send_confirmation_email_view(api): + app = api + with app.app_context(): + normal_user = create_test_user(email='normal@test.com') + confirmed_user = create_test_user(email='confirmed@test.com', + confirmed_at=datetime.datetime.now()) + + db.session.commit() + with app.test_client() as client: + url = url_for('invenio_accounts_rest_auth.send_confirmation') + _login_user(client, normal_user) + res = client.post(url, data=dict(email=normal_user.email)) + + assert_error_resp(res, ( + (None, 'confirmation instructions have been sent'), + ), expected_status_code=200) + + # Already confirmed + res = client.post(url, data=dict(email=confirmed_user.email)) + assert_error_resp(res, ( + (None, 'email has already been confirmed'), + )) + + +def test_confirm_email_view(api): + from flask_security.confirmable import generate_confirmation_token + app = api + with app.app_context(): + normal_user = create_test_user(email='normal@test.com') + confirmed_user = create_test_user(email='confirmed@test.com', + confirmed_at=datetime.datetime.now()) + + db.session.commit() + # Generate token + token = generate_confirmation_token(normal_user) + confirmed_token = generate_confirmation_token(confirmed_user) + with app.test_client() as client: + url = url_for('invenio_accounts_rest_auth.confirm_email') + + # Invalid token + res = client.post(url, data=dict(token='foo')) + payload = get_json(res) + assert 'invalid confirmation token' in \ + payload['message'][0].lower() + + # Already confirmed user + res = client.post(url, data=dict(token=confirmed_token)) + payload = get_json(res) + assert 'email has already been confirmed' in \ + payload['message'].lower() + + # Valid confirm user + assert normal_user.confirmed_at is None + res = client.post(url, data=dict(token=token)) + payload = get_json(res) + assert 'your email has been confirmed' in \ + payload['message'].lower() + assert normal_user.confirmed_at + + +def test_sessions_list_view(api): + app = api + session_total = 3 + with app.app_context(): + normal_user = create_test_user(email='normal@test.com') + db.session.commit() + + url = url_for('invenio_accounts_rest_auth.sessions_list') + + for _ in range(session_total): + with app.test_client() as client: + _login_user(client, normal_user) + + res = client.get(url) + payload = get_json(res) + assert payload['total'] == session_total + assert isinstance(payload['results'], list) + + +def test_sessions_item_view(api): + app = api + with app.app_context(): + normal_user = create_test_user(email='normal@test.com') + db.session.commit() + + old_sid_s = None + with app.test_client() as client: + # Invalid session + _login_user(client, normal_user) + url = url_for('invenio_accounts_rest_auth.sessions_item', + sid_s='foo') + res = client.delete(url) + assert_error_resp(res, ( + (None, 'unable to remove session'), + )) + + # Valid session + sid_s = current_user.active_sessions[0].sid_s + assert current_user + url = url_for('invenio_accounts_rest_auth.sessions_item', + sid_s=sid_s) + res = client.delete(url) + assert_error_resp(res, ( + (None, 'successfully removed'), + (None, 'logged out'), + ), expected_status_code=200) + assert not current_user.active_sessions + + # Login again and save session + _login_user(client, normal_user) + old_sid_s = current_user.active_sessions[0].sid_s + + with app.test_client() as client: + _login_user(client, normal_user) + url = url_for('invenio_accounts_rest_auth.sessions_item', + sid_s=old_sid_s) + res = client.delete(url) + assert_error_resp(res, ( + (None, 'successfully removed'), + (None, 'revoked'), + ), expected_status_code=200) From 9e609b194c9a00e6c8a84d5dfc3f6872cbf62aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Vidal=20Garc=C3=ADa?= Date: Mon, 9 Mar 2020 16:50:48 +0100 Subject: [PATCH 10/10] tests: flexible registration --- invenio_accounts/views/rest.py | 1 - tests/conftest.py | 30 ++++++++++++++++++++++++++++++ tests/test_views_rest.py | 21 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py index e026d55d..a61a540a 100644 --- a/invenio_accounts/views/rest.py +++ b/invenio_accounts/views/rest.py @@ -131,7 +131,6 @@ def create_blueprint(app): class FlaskParser(FlaskParserBase): """Parser to add FieldError to validation errors.""" - # TODO: Add error codes to all messages (e.g. 'user-already-exists') def handle_error(self, error, *args, **kwargs): """Handle errors during parsing.""" if isinstance(error, ValidationError): diff --git a/tests/conftest.py b/tests/conftest.py index a3237303..5cc69a99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -147,6 +147,36 @@ def app_with_redis_url(request): yield app +@pytest.fixture() +def app_with_flexible_registration(request): + """Flask application fixture with Invenio Accounts.""" + from webargs import fields + from invenio_accounts.views.rest import RegisterView, use_kwargs + + class MyRegisterView(RegisterView): + + post_args = { + **RegisterView.post_args, + 'active': fields.Boolean(required=True) + } + + @use_kwargs(post_args) + def post(self, **kwargs): + """Register a user.""" + return super(MyRegisterView, self).post(**kwargs) + + api_app = _app_factory() + InvenioREST(api_app) + InvenioAccountsREST(api_app) + + api_app.config['ACCOUNTS_REST_AUTH_VIEWS']['register'] = MyRegisterView + + api_app.register_blueprint(create_blueprint(api_app)) + + _database_setup(api_app, request) + yield api_app + + @pytest.fixture def script_info(app): """Get ScriptInfo object for testing CLI.""" diff --git a/tests/test_views_rest.py b/tests/test_views_rest.py index 66efbb87..da3d0c37 100644 --- a/tests/test_views_rest.py +++ b/tests/test_views_rest.py @@ -174,6 +174,27 @@ def test_registration_view(api): assert res.status_code == 200 +def test_custom_registration_view(app_with_flexible_registration): + app = app_with_flexible_registration + with app.app_context(): + create_test_user(email='old@test.com') + db.session.commit() + with app.test_client() as client: + url = url_for('invenio_accounts_rest_auth.register') + + # Missing custom field + res = client.post(url, data=dict( + email='new@test.com', password='123456')) + assert_error_resp(res, ( + ('active', 'required'), + )) + + # Successful registration + res = client.post(url, data=dict( + email='new@test.com', password='123456', active=True)) + assert res.status_code == 200 + + def test_logout_view(api): app = api with app.app_context():