From 83e53bda3e3c9c3cda9d604ce0d7495e567de315 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Sun, 16 Jun 2013 22:46:55 -0400 Subject: [PATCH] Add single-user authentication. Closes #11. Adds a single-user password-only authentication mechanism behind which all web client views are protected. The app password, as well as the app's secret key, can be managed from dagobahd.yml. This also includes a site-wide rate limit on bad auth requests of 30 bad requests per minute. This should be invisible to users under normal usage but still prevent any sort of brute force on the app password. --- dagobah/core/core.py | 2 +- dagobah/daemon/api.py | 22 +++++++++++++ dagobah/daemon/app.py | 3 +- dagobah/daemon/auth.py | 50 +++++++++++++++++++++++++++++ dagobah/daemon/daemon.py | 9 ++++++ dagobah/daemon/dagobahd.yml | 8 +++++ dagobah/daemon/static/css/login.css | 3 ++ dagobah/daemon/templates/base.html | 3 ++ dagobah/daemon/templates/login.html | 45 ++++++++++++++++++++++++++ dagobah/daemon/views.py | 4 +++ requirements.txt | 1 + tests/test_api.py | 4 ++- 12 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 dagobah/daemon/auth.py create mode 100644 dagobah/daemon/static/css/login.css create mode 100644 dagobah/daemon/templates/login.html diff --git a/dagobah/core/core.py b/dagobah/core/core.py index 5f1368d..43026c2 100644 --- a/dagobah/core/core.py +++ b/dagobah/core/core.py @@ -52,7 +52,7 @@ def set_backend(self, backend): for job in self.jobs: job.backend = backend - for task in job.tasks: + for task in job.tasks.values(): task.backend = backend self.commit(cascade=True) diff --git a/dagobah/daemon/api.py b/dagobah/daemon/api.py index 3678f6c..d7f1315 100644 --- a/dagobah/daemon/api.py +++ b/dagobah/daemon/api.py @@ -1,6 +1,7 @@ """ HTTP API methods for Dagobah daemon. """ from flask import request, abort +from flask_login import login_required from dagobah.daemon.daemon import app from dagobah.daemon.util import validate_dict, api_call @@ -8,12 +9,14 @@ dagobah = app.config['dagobah'] @app.route('/api/jobs', methods=['GET']) +@login_required @api_call def get_jobs(): return dagobah._serialize().get('jobs', {}) @app.route('/api/job', methods=['GET']) +@login_required @api_call def get_job(): args = dict(request.args) @@ -27,6 +30,7 @@ def get_job(): @app.route('/api/head', methods=['GET']) +@login_required @api_call def head_task(): args = dict(request.args) @@ -51,6 +55,7 @@ def head_task(): @app.route('/api/tail', methods=['GET']) +@login_required @api_call def tail_task(): args = dict(request.args) @@ -75,6 +80,7 @@ def tail_task(): @app.route('/api/add_job', methods=['POST']) +@login_required @api_call def add_job(): args = dict(request.form) @@ -87,6 +93,7 @@ def add_job(): @app.route('/api/delete_job', methods=['POST']) +@login_required @api_call def delete_job(): args = dict(request.form) @@ -99,6 +106,7 @@ def delete_job(): @app.route('/api/start_job', methods=['POST']) +@login_required @api_call def start_job(): args = dict(request.form) @@ -112,6 +120,7 @@ def start_job(): @app.route('/api/retry_job', methods=['POST']) +@login_required @api_call def retry_job(): args = dict(request.form) @@ -125,6 +134,7 @@ def retry_job(): @app.route('/api/add_task_to_job', methods=['POST']) +@login_required @api_call def add_task_to_job(): args = dict(request.form) @@ -141,6 +151,7 @@ def add_task_to_job(): @app.route('/api/delete_task', methods=['POST']) +@login_required @api_call def delete_task(): args = dict(request.form) @@ -155,6 +166,7 @@ def delete_task(): @app.route('/api/add_dependency', methods=['POST']) +@login_required @api_call def add_dependency(): args = dict(request.form) @@ -172,6 +184,7 @@ def add_dependency(): @app.route('/api/delete_dependency', methods=['POST']) +@login_required @api_call def delete_dependency(): args = dict(request.form) @@ -189,6 +202,7 @@ def delete_dependency(): @app.route('/api/schedule_job', methods=['POST']) +@login_required @api_call def schedule_job(): args = dict(request.form) @@ -206,18 +220,21 @@ def schedule_job(): @app.route('/api/stop_scheduler', methods=['POST']) +@login_required @api_call def stop_scheduler(): dagobah.scheduler.stop() @app.route('/api/restart_scheduler', methods=['POST']) +@login_required @api_call def restart_scheduler(): dagobah.scheduler.restart() @app.route('/api/terminate_all_tasks', methods=['POST']) +@login_required @api_call def terminate_all_tasks(): args = dict(request.form) @@ -231,6 +248,7 @@ def terminate_all_tasks(): @app.route('/api/kill_all_tasks', methods=['POST']) +@login_required @api_call def kill_all_tasks(): args = dict(request.form) @@ -244,6 +262,7 @@ def kill_all_tasks(): @app.route('/api/terminate_task', methods=['POST']) +@login_required @api_call def terminate_task(): args = dict(request.form) @@ -261,6 +280,7 @@ def terminate_task(): @app.route('/api/kill_task', methods=['POST']) +@login_required @api_call def kill_task(): args = dict(request.form) @@ -278,6 +298,7 @@ def kill_task(): @app.route('/api/edit_job', methods=['POST']) +@login_required @api_call def edit_job(): args = dict(request.form) @@ -293,6 +314,7 @@ def edit_job(): @app.route('/api/edit_task', methods=['POST']) +@login_required @api_call def edit_task(): args = dict(request.form) diff --git a/dagobah/daemon/app.py b/dagobah/daemon/app.py index be2351e..2cc6648 100644 --- a/dagobah/daemon/app.py +++ b/dagobah/daemon/app.py @@ -1,4 +1,5 @@ -from dagobah.daemon.daemon import app +from dagobah.daemon.daemon import app, login_manager +from dagobah.daemon.auth import * from dagobah.daemon.api import * from dagobah.daemon.views import * diff --git a/dagobah/daemon/auth.py b/dagobah/daemon/auth.py new file mode 100644 index 0000000..ed60717 --- /dev/null +++ b/dagobah/daemon/auth.py @@ -0,0 +1,50 @@ +""" Authentication classes and views for Dagobahd. """ + +from datetime import datetime, timedelta + +from flask import render_template, request, url_for, redirect +from flask_login import UserMixin, login_user, logout_user, login_required + +from dagobah.daemon.app import app, login_manager + +class User(UserMixin): + def get_id(self): + return 1 + +SingleAuthUser = User() + + +@login_manager.user_loader +def load_user(userid): + return SingleAuthUser + + +@app.route('/login', methods=['GET']) +def login(): + return render_template('login.html', alert=request.args.get('alert')) + + +@app.route('/do-login', methods=['POST']) +def do_login(): + """ Attempt to auth using single login. Rate limited at the site level. """ + + dt_filter = lambda x: x >= datetime.utcnow() - timedelta(seconds=60) + app.config['AUTH_ATTEMPTS'] = filter(dt_filter, app.config['AUTH_ATTEMPTS']) + + if len(app.config['AUTH_ATTEMPTS']) > app.config['AUTH_RATE_LIMIT']: + return redirect(url_for('login', + alert="Rate limit exceeded. Try again in 60 seconds.")) + + if request.form.get('password') == app.config['APP_PASSWORD']: + login_user(SingleAuthUser) + return redirect('/') + + app.config['AUTH_ATTEMPTS'].append(datetime.utcnow()) + return redirect(url_for('login', alert="Incorrect password.")) + + +@app.route('/do-logout', methods=['GET', 'POST']) +@login_required +def do_logout(): + logout_user() + return redirect(url_for('login')) diff --git a/dagobah/daemon/daemon.py b/dagobah/daemon/daemon.py index 60e8087..1d8f1fa 100644 --- a/dagobah/daemon/daemon.py +++ b/dagobah/daemon/daemon.py @@ -4,6 +4,7 @@ import logging from flask import Flask, send_from_directory +from flask_login import LoginManager import yaml from dagobah.core import Dagobah, EventHandler @@ -11,6 +12,10 @@ app = Flask(__name__) +login_manager = LoginManager() +login_manager.login_view = "login" +login_manager.init_app(app) + location = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) @@ -68,6 +73,10 @@ def return_standard_conf(): def configure_app(): + app.secret_key = config['Dagobahd']['app_secret'] + app.config['APP_PASSWORD'] = config['Dagobahd']['password'] + app.config['AUTH_RATE_LIMIT'] = 30 + app.config['AUTH_ATTEMPTS'] = [] app.config['APP_HOST'] = config['Dagobahd']['host'] app.config['APP_PORT'] = config['Dagobahd']['port'] diff --git a/dagobah/daemon/dagobahd.yml b/dagobah/daemon/dagobahd.yml index b4d9c6a..cf78a65 100644 --- a/dagobah/daemon/dagobahd.yml +++ b/dagobah/daemon/dagobahd.yml @@ -4,6 +4,14 @@ Dagobahd: host: 127.0.0.1 port: 9000 + # the app's secret key, used for maintaining user sessions + # WARNING: change this to your own random value! + # an easy way to create a key with python is "import os; os.urandom(24)" + app_secret: 'g\xde\xf5\x06@K\xf5:\x1fmZ\xac\x1fO\xe8\xcd\xde\xcf\x90\xaeY7\x8c\x96' + + # credentials for single-user auth + password: dagobah + # choose one of the available backends # None: Dagobah will not use a backend to permanently store data # sqlite: use a SQLite database. see the SQLite section in this file diff --git a/dagobah/daemon/static/css/login.css b/dagobah/daemon/static/css/login.css new file mode 100644 index 0000000..f8d9f29 --- /dev/null +++ b/dagobah/daemon/static/css/login.css @@ -0,0 +1,3 @@ +form { + margin-top: 15px; +} diff --git a/dagobah/daemon/templates/base.html b/dagobah/daemon/templates/base.html index c7d5f51..d02c225 100644 --- a/dagobah/daemon/templates/base.html +++ b/dagobah/daemon/templates/base.html @@ -39,6 +39,9 @@ diff --git a/dagobah/daemon/templates/login.html b/dagobah/daemon/templates/login.html new file mode 100644 index 0000000..3094c25 --- /dev/null +++ b/dagobah/daemon/templates/login.html @@ -0,0 +1,45 @@ +{% extends 'base.html' %} + +{% block head %} +{{ super() }} + +{% endblock head %} + +{% block body_scripts %} + +{% endblock body_scripts %} + +{% block content %} + +
+
+ + {% if alert %} +
{{ alert }}
+ {% endif %} + +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +{% endblock content %} diff --git a/dagobah/daemon/views.py b/dagobah/daemon/views.py index 78344a4..ca3543d 100644 --- a/dagobah/daemon/views.py +++ b/dagobah/daemon/views.py @@ -1,6 +1,7 @@ """ Views for Dagobah daemon. """ from flask import render_template, redirect, url_for +from flask_login import login_required from dagobah.daemon.daemon import app from dagobah.daemon.api import get_jobs @@ -15,6 +16,7 @@ def index_route(): @app.route('/jobs', methods=['GET']) +@login_required def jobs(): """ Show information on all known Jobs. """ return render_template('jobs.html', @@ -22,6 +24,7 @@ def jobs(): @app.route('/job/', methods=['GET']) +@login_required def job_detail(job_id=None): """ Show a detailed description of a Job's status. """ jobs = get_jobs() @@ -30,6 +33,7 @@ def job_detail(job_id=None): @app.route('/job//', methods=['GET']) +@login_required def task_detail(job_id=None, task_name=None): """ Show a detailed description of a specific task. """ jobs = get_jobs() diff --git a/requirements.txt b/requirements.txt index 390b01f..3988cd5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ six==1.3.0 wsgiref==0.1.2 zope.interface==4.0.5 premailer==1.13 +flask-login==0.1.3 diff --git a/tests/test_api.py b/tests/test_api.py index a29bb7c..cb71958 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -16,11 +16,13 @@ def setup_class(self): self.app = app.test_client() self.app.testing = True + self.app.post('/do-login', data={'password': 'dagobah'}) + # force BaseBackend and eliminate registered jobs # picked up from default backend self.dagobah.set_backend(BaseBackend()) for job in self.dagobah.jobs: - job.delete() + self.dagobah.delete_job(job.name) self.base_url = 'http://localhost:60000'