From ea8182ff7639ef1c18774adff699ed963871eb3f Mon Sep 17 00:00:00 2001 From: James Kent Date: Fri, 21 May 2021 14:33:38 -0500 Subject: [PATCH] [ENH] add login (#33) * wip: add user login/activity * add login/auth to test client * make password pass validation * test user creation * add connexion specific function for decoding jwts * setup security with flask-jwt-extended * add openapi updates to security * add flask-jwt-extended to requirements * hacky way to change suffix login and register endpoints * split resources to auth and data * split schemas to auth and data * modify inits to include all functions * update example config * fix style and add assertions * keep flask under 2.0 * keep flask migrate under 3.0.0 --- .env.example | 1 + neurostore/core.py | 28 +++--- neurostore/example_config.py | 37 ++++---- neurostore/models/auth.py | 5 +- neurostore/openapi | 2 +- neurostore/requirements.txt | 4 +- neurostore/resolver.py | 2 +- neurostore/resources/__init__.py | 34 ++++++++ neurostore/resources/auth.py | 85 +++++++++++++++++++ .../resources/{resources.py => data.py} | 3 + neurostore/schemas/__init__.py | 5 +- neurostore/schemas/auth.py | 33 +++++++ neurostore/schemas/{schemas.py => data.py} | 0 neurostore/tests/api/test_user.py | 18 ++++ neurostore/tests/conftest.py | 2 +- neurostore/tests/request_utils.py | 21 ++++- 16 files changed, 243 insertions(+), 37 deletions(-) create mode 100644 neurostore/resources/auth.py rename neurostore/resources/{resources.py => data.py} (98%) create mode 100644 neurostore/schemas/auth.py rename neurostore/schemas/{schemas.py => data.py} (100%) create mode 100644 neurostore/tests/api/test_user.py diff --git a/.env.example b/.env.example index d96ac851c..02e36ce6b 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ COMPOSE_CONVERT_WINDOWS_PATHS=1 POSTGRES_HOST=pgsql POSTGRES_DB=neurostore POSTGRES_PASSWORD=example +BEARERINFO_FUNC=flask_jwt_extended.decode_token \ No newline at end of file diff --git a/neurostore/core.py b/neurostore/core.py index 4e766bbf4..708330931 100644 --- a/neurostore/core.py +++ b/neurostore/core.py @@ -1,13 +1,15 @@ import os from flask_security import Security, SQLAlchemyUserDatastore -from flask_dance.consumer.storage.sqla import SQLAlchemyStorage -from flask_dance.contrib.github import make_github_blueprint +# from flask_dance.consumer.storage.sqla import SQLAlchemyStorage +# from flask_dance.contrib.github import make_github_blueprint from flask_cors import CORS +from flask_jwt_extended import JWTManager + import connexion from .resolver import MethodListViewResolver from .database import init_db -from .models import User, Role, OAuth +from .models import User, Role # OAuth connexion_app = connexion.FlaskApp(__name__, specification_dir="openapi/", debug=True) @@ -16,13 +18,17 @@ app.config.from_object(os.environ["APP_SETTINGS"]) db = init_db(app) +# setup authentication +jwt = JWTManager(app) +app.secret_key = app.config["JWT_SECRET_KEY"] + options = {"swagger_ui": True} connexion_app.add_api( "neurostore-openapi.yml", base_path="/api", options=options, arguments={"title": "NeuroStore API"}, - resolver=MethodListViewResolver("neurostore.resources.resources"), + resolver=MethodListViewResolver("neurostore.resources"), strict_validation=True, validate_responses=True, ) @@ -35,13 +41,13 @@ security = Security(app, user_datastore) # Flask-Dance (OAuth) -app.secret_key = app.config["DANCE_SECRET_KEY"] -blueprint = make_github_blueprint( - client_id=app.config["GITHUB_CLIENT_ID"], - client_secret=app.config["GITHUB_CLIENT_SECRET"], -) -app.register_blueprint(blueprint, url_prefix="/login") -blueprint.storage = SQLAlchemyStorage(OAuth, db.session) +# app.secret_key = app.config["DANCE_SECRET_KEY"] +# blueprint = make_github_blueprint( +# client_id=app.config["GITHUB_CLIENT_ID"], +# client_secret=app.config["GITHUB_CLIENT_SECRET"], +# ) +# app.register_blueprint(blueprint, url_prefix="/login") +# blueprint.storage = SQLAlchemyStorage(OAuth, db.session) # # GraphQL API # from flask_graphql import GraphQLView diff --git a/neurostore/example_config.py b/neurostore/example_config.py index 4ead192bc..f9f87a626 100644 --- a/neurostore/example_config.py +++ b/neurostore/example_config.py @@ -5,49 +5,48 @@ class Config(object): - SERVER_NAME = "localhost" # Set to external server name in production + SERVER_NAME = 'localhost' # Set to external server name in production - MIGRATIONS_DIR = "/migrations/migrations" + MIGRATIONS_DIR = '/migrations/migrations' SQLALCHEMY_TRACK_MODIFICATIONS = False WTF_CSRF_ENABLED = False - POSTGRES_HOST = os.environ.get("POSTGRES_HOST") - POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "") - DB_NAME = "neurostore" - SQLALCHEMY_DATABASE_URI = ( - f"postgres://postgres:" f"{POSTGRES_PASSWORD}@{POSTGRES_HOST}:5432/{DB_NAME}" - ) + POSTGRES_HOST = os.environ.get('POSTGRES_HOST') + POSTGRES_PASSWORD = os.environ.get('POSTGRES_PASSWORD', '') + DB_NAME = 'neurostore' + SQLALCHEMY_DATABASE_URI = f"postgres://postgres:" \ + f"{POSTGRES_PASSWORD}@{POSTGRES_HOST}:5432/{DB_NAME}" PROPAGATE_EXCEPTIONS = True GITHUB_CLIENT_ID = "github-id" GITHUB_CLIENT_SECRET = "github-secret" DANCE_SECRET_KEY = "temporary" + JWT_SECRET_KEY = "also_temporary" - SECURITY_PASSWORD_HASH = "pbkdf2_sha512" - SECURITY_PASSWORD_SALT = "A_SECRET" + SECURITY_PASSWORD_HASH = 'pbkdf2_sha512' + SECURITY_PASSWORD_SALT = 'A_SECRET' class ProductionConfig(Config): - ENV = "production" + ENV = 'production' class DevelopmentConfig(Config): - ENV = "development" + ENV = 'development' DEBUG = True class TestingConfig(Config): - ENV = "testing" + ENV = 'testing' TESTING = True class DockerTestConfig(TestingConfig): - DB_NAME = "test_db" - POSTGRES_HOST = os.environ.get("POSTGRES_HOST") - POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "") - SQLALCHEMY_DATABASE_URI = ( - f"postgres://postgres:" f"{POSTGRES_PASSWORD}@{POSTGRES_HOST}:5432/{DB_NAME}" - ) + DB_NAME = 'test_db' + POSTGRES_HOST = os.environ.get('POSTGRES_HOST') + POSTGRES_PASSWORD = os.environ.get('POSTGRES_PASSWORD', '') + SQLALCHEMY_DATABASE_URI = f'postgres://postgres:' \ + f'{POSTGRES_PASSWORD}@{POSTGRES_HOST}:5432/{DB_NAME}' class TravisConfig(TestingConfig): diff --git a/neurostore/models/auth.py b/neurostore/models/auth.py index cc14efb25..e1fd9bd85 100644 --- a/neurostore/models/auth.py +++ b/neurostore/models/auth.py @@ -1,6 +1,6 @@ from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import relationship, backref -from flask_security import UserMixin, RoleMixin +from flask_security import UserMixin, RoleMixin, SQLAlchemyUserDatastore from flask_dance.consumer.storage.sqla import OAuthConsumerMixin @@ -44,3 +44,6 @@ class OAuth(OAuthConsumerMixin, db.Model): user = relationship(User, backref=backref("oauth")) provider_user_id = db.Column(db.Text, unique=True, nullable=False) provider = db.Column(db.Text) + + +user_datastore = SQLAlchemyUserDatastore(db, User, Role) diff --git a/neurostore/openapi b/neurostore/openapi index 459724c19..576a8b717 160000 --- a/neurostore/openapi +++ b/neurostore/openapi @@ -1 +1 @@ -Subproject commit 459724c19c1a3278aeec26bdf9cb3b4a0229268a +Subproject commit 576a8b7170ba0883a05a42cce8fd3b9f6bb7b0d5 diff --git a/neurostore/requirements.txt b/neurostore/requirements.txt index cf8b937af..7da1a87e2 100644 --- a/neurostore/requirements.txt +++ b/neurostore/requirements.txt @@ -2,10 +2,12 @@ aniso8601>=8.1.0 connexion[swagger-ui]>=2.7.0 email-validator>=1.1.2 flake8>=3.8.4 +flask>=1.1.2,<2.0.0 flask-cors>=3.0.10 flask-dance>=3.2.0 git+https://github.com/graphql-python/flask-graphql.git@0d45561#egg=flask_graphql -flask-migrate>=2.5.3 +flask-jwt-extended>=4.1.0 +flask-migrate>=2.5.3,<3.0.0 flask-script>=2.0.6 flask-security>=3.0.0 gunicorn>=20.0.4 diff --git a/neurostore/resolver.py b/neurostore/resolver.py index 08b984435..0eccca265 100644 --- a/neurostore/resolver.py +++ b/neurostore/resolver.py @@ -36,7 +36,7 @@ def resolve_operation_id(self, operation): # Use RestyResolver to get operation_id for us (follow their naming conventions/structure) operation_id = self.resolve_operation_id_using_rest_semantics(operation) module_name, view_base, meth_name = operation_id.rsplit(".", 2) - if re.search(r"\{.*\}$", operation.path): + if re.search(r"\{.*\}$", operation.path) or operation.path in ['/login', '/register']: view_suffix = "View" else: view_suffix = "ListView" diff --git a/neurostore/resources/__init__.py b/neurostore/resources/__init__.py index e69de29bb..915d9d33b 100644 --- a/neurostore/resources/__init__.py +++ b/neurostore/resources/__init__.py @@ -0,0 +1,34 @@ +from .data import ( + DatasetsView, + StudiesView, + AnalysesView, + ConditionsView, + ImagesView, + PointsView, + PointValueView, + DatasetsListView, + StudiesListView, + AnalysesListView, + ImagesListView, +) + +from .auth import ( + RegisterView, + LoginView +) + +__all__ = [ + "DatasetsView", + "StudiesView", + "AnalysesView", + "ConditionsView", + "ImagesView", + "PointsView", + "PointValueView", + "StudiesListView", + "AnalysesListView", + "ImagesListView", + "DatasetsListView", + "RegisterView", + "LoginView" +] diff --git a/neurostore/resources/auth.py b/neurostore/resources/auth.py new file mode 100644 index 000000000..db4d5d8c3 --- /dev/null +++ b/neurostore/resources/auth.py @@ -0,0 +1,85 @@ +# import datetime + +from flask import jsonify, request +from flask.views import MethodView +from flask_security.utils import verify_password +from flask_jwt_extended import get_jwt_identity, jwt_required, create_access_token +from webargs.flaskparser import parser + +from ..models.auth import User # , user_datastore +from ..schemas import UserSchema # noqa E401 +from ..database import db +from ..core import jwt + + +# Register a callback function that takes whatever object is passed in as the +# identity when creating JWTs and converts it to a JSON serializable format. +@jwt.user_identity_loader +def user_identity_lookup(user): + return user.id + + +# Register a callback function that loades a user from your database whenever +# a protected route is accessed. This should return any python object on a +# successful lookup, or None if the lookup failed for any reason (for example +# if the user has been deleted from the database). +@jwt.user_lookup_loader +def user_lookup_callback(_jwt_header, jwt_data): + identity = jwt_data["sub"] + return User.query.filter_by(id=identity).one_or_none() + + +class RegisterView(MethodView): + _model = User + + @property + def schema(self): + return globals()[self._model.__name__ + 'Schema'] + + @jwt_required + def get(self): + return get_jwt_identity() + + def post(self, **kwargs): + data = parser.parse(self.schema, request) + record = self._model() + # Store all models so we can atomically update in one commit + to_commit = [] + + # Update all non-nested attributes + for k, v in data.items(): + setattr(record, k, v) + + to_commit.append(record) + + db.session.add_all(to_commit) + db.session.commit() + + return self.schema().dump(record) + + +class LoginView(MethodView): + _model = User + + @property + def schema(self): + return globals()[self._model.__name__ + 'Schema'] + + def post(self, **kwargs): + login_schema = self.schema(only=('email', 'password')) + data = login_schema.load(request.json) + # do not want the encrypted password + data['password'] = request.json.get('password') + user = self._model.query.filter_by(email=data['email']).one_or_none() + if not user or not verify_password(data['password'], user.password): + abort(403, 'incorrect email or password') + + user.access_token = create_access_token(identity=user) + + return self.schema(only=('access_token',)).dump(user) + + +def abort(code, message=''): + """ JSONified abort """ + from flask import abort, make_response + abort(make_response(jsonify(message=message), code)) diff --git a/neurostore/resources/resources.py b/neurostore/resources/data.py similarity index 98% rename from neurostore/resources/resources.py rename to neurostore/resources/data.py index 11b7f501f..262ec7dce 100644 --- a/neurostore/resources/resources.py +++ b/neurostore/resources/data.py @@ -6,6 +6,7 @@ from sqlalchemy import func from webargs.flaskparser import parser from webargs import fields +from flask_jwt_extended import jwt_required # jwt_required from ..core import db from ..models import Dataset, Study, Analysis, Condition, Image, Point, PointValue @@ -89,6 +90,7 @@ def get(self, id): record = self._model.query.filter_by(id=id).first_or_404() return self.schema().dump(record) + @jwt_required() def put(self, id): data = parser.parse(self.schema, request) if id != data["id"]: @@ -169,6 +171,7 @@ def search(self): content = self.schema(only=self._only, many=True).dump(records) return jsonify(content), 200, {"X-Total-Count": count} + @jwt_required() def post(self): # TODO: check to make sure current user hasn't already created a # record with most/all of the same details (e.g., DOI for studies) diff --git a/neurostore/schemas/__init__.py b/neurostore/schemas/__init__.py index a1964f882..275437fdf 100644 --- a/neurostore/schemas/__init__.py +++ b/neurostore/schemas/__init__.py @@ -1,4 +1,4 @@ -from .schemas import ( +from .data import ( StudySchema, AnalysisSchema, ConditionSchema, @@ -7,6 +7,8 @@ DatasetSchema, ) +from .auth import UserSchema + __all__ = [ "StudySchema", "AnalysisSchema", @@ -14,4 +16,5 @@ "ImageSchema", "PointSchema", "DatasetSchema", + "UserSchema", ] diff --git a/neurostore/schemas/auth.py b/neurostore/schemas/auth.py new file mode 100644 index 000000000..13cb4f09a --- /dev/null +++ b/neurostore/schemas/auth.py @@ -0,0 +1,33 @@ +from marshmallow import ( + fields, + post_load, + validates, + ValidationError, + EXCLUDE +) +from flask_security.utils import encrypt_password + +from .data import BaseSchema + + +class UserSchema(BaseSchema): + email = fields.Email(required=True) + name = fields.Str(required=True, description='User full name') + username = fields.Str(description='User name', dump_only=True) + password = fields.Str(load_only=True, + description='Password. Minimum 6 characters.') + access_token = fields.Str(dump_only=True) + + @validates('password') + def validate_pass(self, value): + if len(value) < 6: + raise ValidationError('Password must be at least 6 characters.') + + @post_load() + def encrypt_password(self, in_data, **kwargs): + if 'password' in in_data: + in_data['password'] = encrypt_password(in_data['password']) + return in_data + + class Meta: + unknown = EXCLUDE diff --git a/neurostore/schemas/schemas.py b/neurostore/schemas/data.py similarity index 100% rename from neurostore/schemas/schemas.py rename to neurostore/schemas/data.py diff --git a/neurostore/tests/api/test_user.py b/neurostore/tests/api/test_user.py new file mode 100644 index 000000000..75fc608e5 --- /dev/null +++ b/neurostore/tests/api/test_user.py @@ -0,0 +1,18 @@ +def test_create_user(auth_client): + new_user = { + 'email': 'this@that.com', + 'name': "fake name", + 'username': 'user', + 'password': 'more than six characters' + } + auth_client.post("/api/register", data=new_user) + login_resp = auth_client.post( + "/api/login", + data={ + 'email': new_user['email'], + 'password': new_user['password'], + } + ) + + assert login_resp.status_code == 200 + assert 'access_token' in login_resp.json.keys() diff --git a/neurostore/tests/conftest.py b/neurostore/tests/conftest.py index 731494e02..9502ba6b1 100644 --- a/neurostore/tests/conftest.py +++ b/neurostore/tests/conftest.py @@ -93,7 +93,7 @@ def add_users(app, db, session): user_datastore = SQLAlchemyUserDatastore(db, User, Role) user1 = "test1@gmail.com" - pass1 = "test1" + pass1 = "testtest1" user_datastore.create_user( email=user1, diff --git a/neurostore/tests/request_utils.py b/neurostore/tests/request_utils.py index 3dc96d4cd..e3eb67a07 100644 --- a/neurostore/tests/request_utils.py +++ b/neurostore/tests/request_utils.py @@ -14,12 +14,19 @@ def __init__(self, test_client=None, prepend="", email=None, password=None): self.client = test_client self.prepend = prepend + self.token = None if email is not None and password is not None: self.email = email self.password = password self.authorize(email, password) + def _get_headers(self): + if self.token is not None: + return {'Authorization': 'Bearer %s' % self.token} + else: + return None + def _make_request( self, request, @@ -32,6 +39,7 @@ def _make_request( ): """ Generic request handler """ request_function = getattr(self.client, request) + headers = headers or self._get_headers() if content_type is None: content_type = "application/json" @@ -53,7 +61,18 @@ def _make_request( return request_function(route, json=data, headers=headers, params=params) def authorize(self, email=None, password=None): - pass + if email is not None and password is not None: + self.email = email + self.password = password + + rv = self.post( + '/api/login', + data={'email': self.email, 'password': self.password}) + + if self.client_flask: + self.token = json.loads(rv.data.decode())['access_token'] + else: + self.token = rv.json()['access_token'] get = partialmethod(_make_request, "get") post = partialmethod(_make_request, "post")