Skip to content

Commit

Permalink
[ENH] add login (#33)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jdkent authored May 21, 2021
1 parent 3684138 commit ea8182f
Show file tree
Hide file tree
Showing 16 changed files with 243 additions and 37 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 17 additions & 11 deletions neurostore/core.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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,
)
Expand All @@ -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
Expand Down
37 changes: 18 additions & 19 deletions neurostore/example_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
5 changes: 4 additions & 1 deletion neurostore/models/auth.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion neurostore/openapi
Submodule openapi updated 1 files
+75 −1 neurostore-openapi.yml
4 changes: 3 additions & 1 deletion neurostore/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion neurostore/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions neurostore/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
]
85 changes: 85 additions & 0 deletions neurostore/resources/auth.py
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion neurostore/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .schemas import (
from .data import (
StudySchema,
AnalysisSchema,
ConditionSchema,
Expand All @@ -7,11 +7,14 @@
DatasetSchema,
)

from .auth import UserSchema

__all__ = [
"StudySchema",
"AnalysisSchema",
"ConditionSchema",
"ImageSchema",
"PointSchema",
"DatasetSchema",
"UserSchema",
]
33 changes: 33 additions & 0 deletions neurostore/schemas/auth.py
Original file line number Diff line number Diff line change
@@ -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
File renamed without changes.
18 changes: 18 additions & 0 deletions neurostore/tests/api/test_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
def test_create_user(auth_client):
new_user = {
'email': '[email protected]',
'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()
Loading

0 comments on commit ea8182f

Please sign in to comment.