Skip to content

Commit

Permalink
Add single-user authentication. Closes #11.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
thieman committed Jun 17, 2013
1 parent 3519cca commit 83e53bd
Show file tree
Hide file tree
Showing 12 changed files with 151 additions and 3 deletions.
2 changes: 1 addition & 1 deletion dagobah/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions dagobah/daemon/api.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
""" 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

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)
Expand All @@ -27,6 +30,7 @@ def get_job():


@app.route('/api/head', methods=['GET'])
@login_required
@api_call
def head_task():
args = dict(request.args)
Expand All @@ -51,6 +55,7 @@ def head_task():


@app.route('/api/tail', methods=['GET'])
@login_required
@api_call
def tail_task():
args = dict(request.args)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion dagobah/daemon/app.py
Original file line number Diff line number Diff line change
@@ -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 *

Expand Down
50 changes: 50 additions & 0 deletions dagobah/daemon/auth.py
Original file line number Diff line number Diff line change
@@ -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'))
9 changes: 9 additions & 0 deletions dagobah/daemon/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
import logging

from flask import Flask, send_from_directory
from flask_login import LoginManager
import yaml

from dagobah.core import Dagobah, EventHandler
from dagobah.email import get_email_handler

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__)))

Expand Down Expand Up @@ -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']

Expand Down
8 changes: 8 additions & 0 deletions dagobah/daemon/dagobahd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions dagobah/daemon/static/css/login.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
form {
margin-top: 15px;
}
3 changes: 3 additions & 0 deletions dagobah/daemon/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@

<ul id='navbar-right' class='nav pull-right'>
{% block navbar_current %}{% endblock navbar_current %}
{% if current_user.is_authenticated() %}
<li id='logout'><a href={{ url_for('do_logout') }}><i class='icon icon-off'></i></a></li>
{% endif %}
</ul>

</div>
Expand Down
45 changes: 45 additions & 0 deletions dagobah/daemon/templates/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{% extends 'base.html' %}

{% block head %}
{{ super() }}
<link rel="stylesheet" type="text/css" href="/static/css/login.css"></link>
{% endblock head %}

{% block body_scripts %}
<script>
$(document).ready(function() {
$('#password').select();
});
</script>
{% endblock body_scripts %}

{% block content %}

<div class='row'>
<div class='span6 offset3'>

{% if alert %}
<div class='alert alert-error'>{{ alert }}</div>
{% endif %}

<form class='form form-horizontal' action='{{ url_for('do_login') }}' method='post'>

<div class='control-group'>
<label class='control-label' for='password'>Password</label>
<div class='controls'>
<input type='password' id='password' name='password' placeholder='Password'></input>
</div>
</div>

<div class='control-group'>
<div class='controls'>
<button id='submit' type='submit' class='btn btn-success'>Authenticate</button>
</div>
</div>

</form>

</div>
</div>

{% endblock content %}
4 changes: 4 additions & 0 deletions dagobah/daemon/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,13 +16,15 @@ def index_route():


@app.route('/jobs', methods=['GET'])
@login_required
def jobs():
""" Show information on all known Jobs. """
return render_template('jobs.html',
jobs=get_jobs())


@app.route('/job/<job_id>', methods=['GET'])
@login_required
def job_detail(job_id=None):
""" Show a detailed description of a Job's status. """
jobs = get_jobs()
Expand All @@ -30,6 +33,7 @@ def job_detail(job_id=None):


@app.route('/job/<job_id>/<task_name>', methods=['GET'])
@login_required
def task_detail(job_id=None, task_name=None):
""" Show a detailed description of a specific task. """
jobs = get_jobs()
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'})

This comment has been minimized.

Copy link
@thieman

thieman Jun 17, 2013

Author Owner

Whoooops, will push a fix and rebase


# 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'

Expand Down

0 comments on commit 83e53bd

Please sign in to comment.