diff --git a/ENVTEMPLATE.sh b/ENVTEMPLATE.sh index c3b8607..884c539 100644 --- a/ENVTEMPLATE.sh +++ b/ENVTEMPLATE.sh @@ -6,6 +6,8 @@ export WEBHDFS_USER=username export WEBHDFS_URL=http://example.com:5000 export IGV_HTTPFS_URL=http://example.com:9876 export ALLOW_VCF_OVERWRITES=False +export BCRYPT_LOG_ROUNDS=12 +export SECRET_KEY # Set to something real + random for deployment # True for automatic reloading & debugging JS insertion. export USE_RELOADER=False diff --git a/config.py b/config.py index 39de55a..7394d0d 100644 --- a/config.py +++ b/config.py @@ -1,10 +1,13 @@ import os +import subprocess + def handle_false(value): # ensure that false in config isn't interpreted as True - if value and value.lower() == 'false': - value = False - return value + if value and value.lower() == 'false' or not value: + return False + return True + USE_RELOADER = handle_false(os.environ.get('USE_RELOADER', False)) SQLALCHEMY_DATABASE_URI = os.environ['DATABASE_URI'] @@ -18,7 +21,6 @@ def handle_false(value): TYPEKIT_URL = os.environ.get('TYPEKIT_URL', None) -import subprocess try: DEPLOYED_GIT_HASH = subprocess.check_output(['git', 'rev-parse', 'HEAD'])[:-1] except subprocess.CalledProcessError: @@ -29,4 +31,14 @@ def handle_false(value): TRAVIS = os.environ.get('TRAVIS') ENSEMBL_RELEASE = os.environ.get('ENSEMBL_RELEASE', 75) +SECRET_KEY = os.environ['SECRET_KEY'] +BCRYPT_LOG_ROUNDS = int(os.environ.get('BCRYPT_LOG_ROUNDS', 10)) + +# Used to disable the @login_required decorator for e.g. seltest +# cf. http://flask-login.readthedocs.org/en/latest/ "Protecting views" +LOGIN_DISABLED = handle_false(os.environ.get('LOGIN_DISABLED', False)) + + del os +del subprocess +del handle_false diff --git a/cycledash/__init__.py b/cycledash/__init__.py index 184d27b..585ace0 100644 --- a/cycledash/__init__.py +++ b/cycledash/__init__.py @@ -1,6 +1,6 @@ from flask import Flask, jsonify, request from flask_sqlalchemy import SQLAlchemy -from flask.ext import restful +from flask.ext import restful, login, bcrypt import humanize import logging import sys @@ -12,10 +12,20 @@ def initialize_application(): _configure_application(app) _configure_logging(app) _configure_templates(app) + _configure_extensions(app) return app +def _configure_extensions(app): + global db, api, login_manager, bcrypt + db = SQLAlchemy(app) + api = restful.Api(app, prefix='/api', catch_all_404s=True) + bcrypt = bcrypt.Bcrypt(app) + login_manager = login.LoginManager() + login_manager.init_app(app) + + def _configure_logging(app): stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setLevel(logging.INFO) @@ -39,8 +49,7 @@ def humanize_date(time): app = initialize_application() -db = SQLAlchemy(app) -api = restful.Api(app, prefix='/api', catch_all_404s=True) import cycledash.views +import cycledash.auth diff --git a/cycledash/api/__init__.py b/cycledash/api/__init__.py new file mode 100644 index 0000000..699cca7 --- /dev/null +++ b/cycledash/api/__init__.py @@ -0,0 +1,36 @@ +from flask import request +import flask.ext.restful +from flask.ext.login import current_user + +from cycledash import login_manager +from cycledash.auth import check_login + + +class Resource(flask.ext.restful.Resource, object): + """Extends Resource by adding an authentication check for basic auth or + valid session cokie. + """ + def __init__(self, *args, **kwargs): + self.auth = getattr(self, 'require_auth', False) + super(Resource, self).__init__(*args, **kwargs) + + def dispatch_request(self, *args, **kwargs): + if self.auth: + authorized = False + if login_manager._login_disabled: + # This is used for tests. + authorized = True + elif current_user.is_authenticated(): + authorized = True + elif request.authorization: + username = request.authorization.username + password = request.authorization.password + if check_login(username, password): + authorized = True + if not authorized: + auth_msg = 'Correct username/password required.' + return flask.ext.restful.abort(401, message=auth_msg) + return super(Resource, self).dispatch_request(*args, **kwargs) + + +import projects, bams, runs, genotypes, tasks, comments diff --git a/cycledash/bams.py b/cycledash/api/bams.py similarity index 94% rename from cycledash/bams.py rename to cycledash/api/bams.py index 656a012..a6ecaac 100644 --- a/cycledash/bams.py +++ b/cycledash/api/bams.py @@ -1,6 +1,6 @@ """Defines the API for BAMs.""" from flask import request -from flask.ext.restful import abort, Resource, fields +from flask.ext.restful import abort, fields from sqlalchemy import select, desc import voluptuous @@ -8,9 +8,11 @@ from cycledash.validations import CreateBam, UpdateBam, expect_one_of from cycledash import db from cycledash.helpers import validate_with, abort_if_none_for, marshal_with -import cycledash.projects import workers.indexer +import projects +from . import Resource + bam_fields = { "id": fields.Integer, @@ -25,6 +27,7 @@ class BamList(Resource): + require_auth = True @marshal_with(bam_fields, envelope='bams') def get(self): """Get list of all BAMs.""" @@ -44,7 +47,7 @@ def post(self): errors = [str(err) for err in e.errors] abort(409, message='Validation error', errors=errors) try: - cycledash.projects.set_and_verify_project_id_on(request.validated_body) + projects.set_and_verify_project_id_on(request.validated_body) except voluptuous.Invalid as e: abort(404, message='Project not found.', error=str(e)) with tables(db.engine, 'bams') as (con, bams): @@ -56,6 +59,7 @@ def post(self): class Bam(Resource): + require_auth = True @marshal_with(bam_fields) def get(self, bam_id): """Get a BAM by its ID.""" diff --git a/cycledash/comments.py b/cycledash/api/comments.py similarity index 97% rename from cycledash/comments.py rename to cycledash/api/comments.py index e907a96..ed7f2e1 100644 --- a/cycledash/comments.py +++ b/cycledash/api/comments.py @@ -1,7 +1,7 @@ """API for user comments.""" from collections import defaultdict from flask import jsonify, request -from flask.ext.restful import abort, Resource, fields +from flask.ext.restful import abort, fields from sqlalchemy import select, func, desc from common.helpers import tables, to_epoch @@ -11,6 +11,8 @@ marshal_with, camelcase_dict) from cycledash.validations import CreateComment, DeleteComment, UpdateComment +from . import Resource + comment_fields = { "id": fields.Integer, @@ -28,6 +30,7 @@ class CommentList(Resource): + require_auth = True @marshal_with(comment_fields, envelope='comments') def get(self, run_id): """Get a list of all comments.""" @@ -49,6 +52,7 @@ def post(self, run_id): class Comment(Resource): + require_auth = True @marshal_with(comment_fields) def get(self, run_id, comment_id): """Get comment with the given ID.""" @@ -93,6 +97,7 @@ def delete(self, run_id, comment_id): class CommentsForVcf(Resource): + require_auth = True def get(self, run_id): """Returns comments keys by their record ID.""" # Ideally, this would be done on the client-side with the basic API diff --git a/cycledash/genotypes.py b/cycledash/api/genotypes.py similarity index 99% rename from cycledash/genotypes.py rename to cycledash/api/genotypes.py index 6ebd6a9..8e0666b 100644 --- a/cycledash/genotypes.py +++ b/cycledash/api/genotypes.py @@ -2,10 +2,8 @@ from collections import OrderedDict import copy import json - from flask import request import flask.ext.restful as restful -from flask.ext.restful import Resource from sqlalchemy import (select, func, types, cast, join, outerjoin, asc, desc, and_, Integer, Float, String, distinct) from sqlalchemy.sql import text @@ -17,8 +15,11 @@ from cycledash import db from common.helpers import tables +from . import Resource + class Genotypes(Resource): + require_auth = True def get(self, run_id): return get(run_id, json.loads(request.args.get('q'))) diff --git a/cycledash/projects.py b/cycledash/api/projects.py similarity index 95% rename from cycledash/projects.py rename to cycledash/api/projects.py index 6e8a5da..4999640 100644 --- a/cycledash/projects.py +++ b/cycledash/api/projects.py @@ -1,17 +1,17 @@ """Defines the API for Projects.""" from flask import request, redirect, jsonify, url_for, render_template -from flask.ext.restful import Resource, fields, abort +from flask.ext.restful import fields, abort from sqlalchemy import exc, select, func, desc import voluptuous from common.helpers import tables, find from cycledash import db -import cycledash.tasks -import cycledash.bams from cycledash.helpers import (get_id_where, abort_if_none_for, validate_with, marshal_with) from cycledash.validations import CreateProject, UpdateProject +from . import bams as bamsapi, tasks as tasksapi, Resource + project_fields = { 'id': fields.Integer, @@ -21,6 +21,7 @@ class ProjectList(Resource): + require_auth = True @marshal_with(project_fields, envelope='projects') def get(self): """Get list of all projects.""" @@ -43,6 +44,7 @@ def post(self): class Project(Resource): + require_auth = True @marshal_with(project_fields) def get(self, project_id): """Get a project by its ID.""" @@ -101,7 +103,7 @@ def get_projects_tree(): q = select(projects.c) projects = [dict(b) for b in con.execute(q).fetchall()] - cycledash.bams.attach_bams_to_vcfs(vcfs) + bamsapi.attach_bams_to_vcfs(vcfs) for vcf in vcfs: project_id = vcf.get('project_id') @@ -124,7 +126,7 @@ def get_projects_tree(): def _join_task_states(vcfs): """Add a task_states field to each VCF in a list of VCFs.""" - ts = cycledash.tasks.all_non_success_tasks() + ts = tasksapi.all_non_success_tasks() for vcf in vcfs: vcf['task_states'] = ts.get(vcf['id'], []) diff --git a/cycledash/runs.py b/cycledash/api/runs.py similarity index 94% rename from cycledash/runs.py rename to cycledash/api/runs.py index 92b2d83..97d282a 100644 --- a/cycledash/runs.py +++ b/cycledash/api/runs.py @@ -1,17 +1,17 @@ from flask import request -from flask.ext.restful import abort, Resource, fields +from flask.ext.restful import abort, fields from sqlalchemy import select, desc, func import voluptuous -from cycledash import db, genotypes +from cycledash import db from cycledash.helpers import (get_id_where, get_where, abort_if_none_for, validate_with, marshal_with) -import cycledash.bams -import cycledash.projects from cycledash.validations import CreateRun, UpdateRun, expect_one_of from common.helpers import tables import workers.runner +from . import genotypes, bams, projects, Resource + run_fields = { 'id': fields.Integer, @@ -35,6 +35,7 @@ class RunList(Resource): + require_auth = True @marshal_with(run_fields, envelope='runs') def get(self): """Get list of all runs in order of recency.""" @@ -57,7 +58,7 @@ def post(self): errors = [str(err) for err in e.errors] abort(409, message='Validation error', errors=errors) try: - cycledash.projects.set_and_verify_project_id_on(run) + projects.set_and_verify_project_id_on(run) except voluptuous.Invalid as e: abort(404, message='Project not found.', errors=[str(e)]) try: @@ -74,13 +75,14 @@ def post(self): class Run(Resource): + require_auth = True @marshal_with(thick_run_fields) def get(self, run_id): """Return a vcf with a given ID.""" with tables(db.engine, 'vcfs') as (con, runs): q = select(runs.c).where(runs.c.id == run_id) run = dict(_abort_if_none(q.execute().fetchone(), run_id)) - cycledash.bams.attach_bams_to_vcfs([run]) + bams.attach_bams_to_vcfs([run]) return run @validate_with(UpdateRun) diff --git a/cycledash/tasks.py b/cycledash/api/tasks.py similarity index 95% rename from cycledash/tasks.py rename to cycledash/api/tasks.py index b364eff..ff36128 100644 --- a/cycledash/tasks.py +++ b/cycledash/api/tasks.py @@ -1,7 +1,7 @@ """Methods for working with Celery task states.""" from collections import defaultdict from sqlalchemy import select -from flask.ext.restful import abort, Resource, fields +from flask.ext.restful import abort, fields from common.helpers import tables from cycledash.helpers import marshal_with @@ -9,6 +9,8 @@ from workers.shared import update_tasks_table, worker import workers.runner +from . import Resource + task_fields = { 'state': fields.String, @@ -18,6 +20,7 @@ class TaskList(Resource): + require_auth = True @marshal_with(task_fields, envelope='tasks') def get(self, run_id): with tables(db.engine, 'task_states') as (con, tasks): @@ -37,6 +40,7 @@ def delete(self, run_id): class TasksRestart(Resource): + require_auth = True def post(self, run_id): restart_failed_tasks_for(run_id) return {'message': 'Restarting failed tasks (run_id={})'.format(run_id)} diff --git a/cycledash/auth.py b/cycledash/auth.py new file mode 100644 index 0000000..27fc3e7 --- /dev/null +++ b/cycledash/auth.py @@ -0,0 +1,114 @@ +# Module to manage user authentication and identification +from flask import request, redirect, render_template +from flask.ext.login import login_user, logout_user +from sqlalchemy import exc +import voluptuous + +from cycledash import db, bcrypt, login_manager +from cycledash.helpers import prepare_request_data, safely_redirect_to_next +from common.helpers import tables +from validations import RegisterUser, LoginUser + + +login_manager.login_view = 'login' + +def load_user(user_id): + with tables(db.engine, 'users') as (con, users): + user = users.select(users.c.id == user_id).execute().fetchone() + if user: + return wrap_user(user) + +login_manager.user_loader(load_user) + + +def login(): + """Attempt to login a user from the current request. + + Return the user if successful, else raise.""" + try: + data = LoginUser(prepare_request_data(request)) + except voluptuous.MultipleInvalid as err: + render_template('login.html', errors=str(err)) + user = check_login(data['username'], data['password']) + if user: + login_user(user) + return safely_redirect_to_next('home') + return render_template('login.html', errors='No such user.') + + +def check_login(username, password): + """Returns the user if exists and password is correct, else None.""" + if not (username and password): + return None + user = None + with tables(db.engine, 'users') as (con, users): + user = users.select(users.c.username == username).execute().fetchone() + if user: + pw_hash = str(user['password']) + candidate = str(password) + if bcrypt.check_password_hash(pw_hash, candidate): + return wrap_user(user) + return None + + +class User(dict): + def is_authenticated(self): + return True + + def is_active(self): + return True + + def is_anonymous(self): + return False + + def get_id(self): + return unicode(self['id']) + + +def wrap_user(user): + """Wraps user record returned from the database in a class providing methods + that flask-login expects. + """ + return User(user) + + +def logout(): + """Logs the logged in user, if any, out.""" + logout_user() + return redirect('about') + + +def register(): + """Register user from current request. + + Returns the user if successful, else raise.""" + errors = None + user = None + try: + data = RegisterUser(prepare_request_data(request)) + if data.get('password1') != data.get('password2'): + errors = 'Passwords must match.' + except voluptuous.MultipleInvalid as err: + errors = str(err) + if not errors: + with tables(db.engine, 'users') as (con, users): + try: + password_hash = bcrypt.generate_password_hash( + request.form['password1']) + user = users.insert({ + 'username': request.form['username'], + 'password': password_hash, + 'email': request.form['email'] + }).returning(*users.c).execute().fetchone() + except exc.IntegrityError as e: + errors = "Can\'t create user: " + str(e) + if user: + class d2(dict): pass + user = d2(user) + setattr(user, 'is_authenticated', True) + setattr(user, 'is_active', lambda: True) + setattr(user, 'is_anonymous', False) + setattr(user, 'get_id', lambda: unicode(user['id'])) + login_user(user) + return redirect('/') + return render_template('register.html', errors=errors) diff --git a/cycledash/helpers.py b/cycledash/helpers.py index a1c8c99..4587128 100644 --- a/cycledash/helpers.py +++ b/cycledash/helpers.py @@ -3,11 +3,12 @@ import json import os import re +from urlparse import urlparse, urljoin from cycledash import db from common.helpers import tables, to_epoch -from flask import jsonify, request +from flask import jsonify, request, url_for, redirect import flask.ext.restful, flask.ext.restful.fields import voluptuous from werkzeug.utils import secure_filename @@ -203,6 +204,26 @@ def wrapper(*args, **kwargs): return decorator +def is_safe_url(target): + """Make sure a redirect target is to the same server + + This is used to prevent open redirects. + + cf. http://flask.pocoo.org/snippets/62/.""" + ref_url = urlparse(request.host_url) + test_url = urlparse(urljoin(request.host_url, target)) + return test_url.scheme in ('http', 'https') and \ + ref_url.netloc == test_url.netloc + + +def safely_redirect_to_next(fallback_endpoint, **values): + """Redirect to `next` endpoint if safe, else to `fallback_endpoint`.""" + target = request.form.get('next') + if not target or not is_safe_url(target): + target = url_for(fallback_endpoint, **values) + return redirect(target) + + def abort_if_none_for(obj_name): def abort_if_none(obj, obj_id): """Abort request with a 404 if object is None.""" diff --git a/cycledash/static/scss/header.scss b/cycledash/static/scss/header.scss index 5abb792..763fa21 100644 --- a/cycledash/static/scss/header.scss +++ b/cycledash/static/scss/header.scss @@ -34,4 +34,15 @@ nav.navigation .active a:hover { } nav.navigation .active { font-weight: bold; -} \ No newline at end of file +} +nav #user-login-logout { + float: right; +} + +nav #user-login-logout { + float: right; + #logout { + border: none; + background: none; + } +} diff --git a/cycledash/static/scss/style.scss b/cycledash/static/scss/style.scss index 4a0b892..53de563 100644 --- a/cycledash/static/scss/style.scss +++ b/cycledash/static/scss/style.scss @@ -99,10 +99,21 @@ label { font-size: 0.875em; color: $color-text-dim; } +.form-errors { + padding: 0.6em; +} .form-group.required .control-label:after { content:"*"; color: $color-red; } +form.centered-form { + width: 500px; + background: $color-bg-body-dark; + border: 1px solid $color-border-light; + border-radius: $radius; + margin: 2em auto; + padding: 1em 2em 2em 2em; +} section#api-docs > section { @extend %card; margin-bottom: 2em; diff --git a/cycledash/templates/about.html b/cycledash/templates/about.html index 1a92993..cef774c 100644 --- a/cycledash/templates/about.html +++ b/cycledash/templates/about.html @@ -6,7 +6,7 @@ {% endblock %} {% block body %} -{{ nav("about") }} +{{ nav("about", current_user) }}

Welcome to CycleDash

diff --git a/cycledash/templates/comments.html b/cycledash/templates/comments.html index c25e175..5a8c4d4 100644 --- a/cycledash/templates/comments.html +++ b/cycledash/templates/comments.html @@ -6,7 +6,7 @@ {% endblock %} {% block body %} -{{ nav("comments") }} +{{ nav("comments", current_user) }}
diff --git a/cycledash/templates/layouts/layout.html b/cycledash/templates/layouts/layout.html index 4bf7630..0d63327 100644 --- a/cycledash/templates/layouts/layout.html +++ b/cycledash/templates/layouts/layout.html @@ -2,7 +2,7 @@ - {% block title %}CycleDash{% endblock %} + {% block title %}Cycledash{% endblock %} diff --git a/cycledash/templates/login.html b/cycledash/templates/login.html new file mode 100644 index 0000000..ae2bc05 --- /dev/null +++ b/cycledash/templates/login.html @@ -0,0 +1,32 @@ +{% extends "layouts/layout.html" %} +{%- from 'macros/nav.html' import nav -%} + +{% block head %} + +{% endblock %} + +{% block body %} +{{ nav("login", current_user) }} +
+
+

Login to Cycledash

+ {% if errors %} +

{{errors}}

+ {% endif %} +
+ + +
+
+ + +
+
+ +
+ Register here if you don't have an account +
+
+{% endblock %} diff --git a/cycledash/templates/macros/nav.html b/cycledash/templates/macros/nav.html index 9e921c4..06c986c 100644 --- a/cycledash/templates/macros/nav.html +++ b/cycledash/templates/macros/nav.html @@ -1,8 +1,8 @@ -{% macro nav(active) -%} +{% macro nav(active, current_user) -%} {%- endmacro %} diff --git a/cycledash/templates/register.html b/cycledash/templates/register.html new file mode 100644 index 0000000..330f794 --- /dev/null +++ b/cycledash/templates/register.html @@ -0,0 +1,43 @@ +{% extends "layouts/layout.html" %} +{%- from 'macros/nav.html' import nav -%} + +{% block head %} + +{% endblock %} + +{% block body %} +{{ nav("register", current_user) }} +
+
+

Register for Cycledash

+ {% if errors %} +

{{errors}}

+ {% endif %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+{% endblock %} diff --git a/cycledash/templates/runs.html b/cycledash/templates/runs.html index e886d4f..c4ed25d 100644 --- a/cycledash/templates/runs.html +++ b/cycledash/templates/runs.html @@ -6,7 +6,7 @@ {% endblock %} {% block body %} -{{ nav("runs") }} +{{ nav("runs", current_user) }}