diff --git a/servecerts/__init__.py b/servecerts/__init__.py new file mode 100644 index 0000000..c700a64 --- /dev/null +++ b/servecerts/__init__.py @@ -0,0 +1,49 @@ +import os + +from flask import Flask + +from flask_debugtoolbar import DebugToolbarExtension +from logging.handlers import RotatingFileHandler +import logging + + +def create_app(test_config=None): + # create and configure the app + app = Flask(__name__, instance_relative_config=True) + app.config.from_mapping( + SECRET_KEY='dev', + DATABASE=os.path.join(app.instance_path, 'servecerts.sqlite'), + ) + + app.debug = True + + if test_config is None: + # load the instance config, if it exists, when not testing + app.config.from_pyfile('config.py', silent=True) + else: + # load the test config if passed in + app.config.from_mapping(test_config) + + # ensure the instance folder exists + try: + os.makedirs(app.instance_path) + except OSError: + pass + + # a simple page that says hello + @app.route('/hello') + def hello(): + return 'Hello World!' + + from . import db + db.init_app(app) + + from . import auth + app.register_blueprint(auth.bp) + + from . import pubkeys + app.register_blueprint(pubkeys.bp) + app.add_url_rule('/', endpoint='pubkeys.index') + app.add_url_rule('/pubkeys/', endpoint='index') + + return app diff --git a/servecerts/auth.py b/servecerts/auth.py new file mode 100644 index 0000000..bc8dfa5 --- /dev/null +++ b/servecerts/auth.py @@ -0,0 +1,146 @@ +import functools + +from flask import ( + Blueprint, flash, g, redirect, render_template, request, session, url_for +) +from werkzeug.security import check_password_hash, generate_password_hash + +from servecerts.db import get_db + +from flask_debugtoolbar import DebugToolbarExtension +import logging + +bp = Blueprint('auth', __name__, url_prefix='/auth') + +@bp.route('/register', methods=('GET', 'POST')) +def register(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + fullname = request.form['fullname'] + email = request.form['email'] + db = get_db() + error = None + + if not username: + error = 'Username is required.' + elif not password: + error = 'Password is required.' + elif not fullname: + error = 'Fullname is required.' + elif not email: + error = 'Email is required.' + elif db.execute( + 'SELECT id FROM user WHERE username = ?', (username,) + ).fetchone() is not None: + error = 'User {} is already registered.'.format(username) + + if error is None: + db.execute( + 'INSERT INTO user (username, password, fullname, email) VALUES (?, ?, ?, ?)', + (username, generate_password_hash(password), fullname, email) + ) + db.commit() + return redirect(url_for('auth.login')) + + flash(error) + + return render_template('auth/register.html') + +@bp.route('//update', methods=('GET', 'POST')) +def update(id): + user = get_user(id) + + if request.method == 'POST': + username = request.form['username'] + fullname = request.form['fullname'] + password = request.form['password'] + email = request.form['email'] + error = None + + if not username: + error = 'Username is required.' + elif not password: + error = 'Password is required.' + elif not fullname: + error = 'Fullname is required.' + elif not email: + error = 'Email is required.' + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + 'UPDATE user SET username = ?, password = ?, fullname = ?, email = ?' + ' WHERE id = ?', + (username, generate_password_hash(password), fullname, email, id) + ) + db.commit() + return redirect(url_for('pubkeys.index')) + + + return render_template('auth/update.html', user=user) + +@bp.route('/login', methods=('GET', 'POST')) +def login(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + db = get_db() + error = None + user = db.execute( + 'SELECT * FROM user WHERE username = ?', (username,) + ).fetchone() + + if user is None: + error = 'Incorrect username.' + elif not check_password_hash(user['password'], password): + error = 'Incorrect password.' + + if error is None: + session.clear() + session['user_id'] = user['id'] + return redirect(url_for('pubkeys.index')) + + flash(error) + + return render_template('auth/login.html') + +@bp.before_app_request +def load_logged_in_user(): + user_id = session.get('user_id') + + if user_id is None: + g.user = None + else: + g.user = get_db().execute( + 'SELECT * FROM user WHERE id = ?', (user_id,) + ).fetchone() + +@bp.route('/logout') +def logout(): + session.clear() + return redirect(url_for('pubkeys.index')) + +def login_required(view): + @functools.wraps(view) + def wrapped_view(**kwargs): + if g.user is None: + return redirect(url_for('auth.login')) + + return view(**kwargs) + + return wrapped_view + + +def get_user(id): + user = get_db().execute( + 'SELECT * FROM user WHERE id = ?', (id,) + ).fetchone() + + if user is None: + abort(404, "User id {0} does not exist.".format(id)) + + return user + diff --git a/servecerts/db.py b/servecerts/db.py new file mode 100644 index 0000000..44bab9e --- /dev/null +++ b/servecerts/db.py @@ -0,0 +1,41 @@ +import sqlite3 + +import click +from flask import current_app, g +from flask.cli import with_appcontext + + +def get_db(): + if 'db' not in g: + g.db = sqlite3.connect( + current_app.config['DATABASE'], + detect_types=sqlite3.PARSE_DECLTYPES + ) + g.db.row_factory = sqlite3.Row + + return g.db + + +def close_db(e=None): + db = g.pop('db', None) + + if db is not None: + db.close() + +def init_db(): + db = get_db() + + with current_app.open_resource('schema.sql') as f: + db.executescript(f.read().decode('utf8')) + + +@click.command('init-db') +@with_appcontext +def init_db_command(): + """Clear the existing data and create new tables.""" + init_db() + click.echo('Initialized the database.') + +def init_app(app): + app.teardown_appcontext(close_db) + app.cli.add_command(init_db_command) diff --git a/servecerts/pubkeys.py b/servecerts/pubkeys.py new file mode 100644 index 0000000..2eee576 --- /dev/null +++ b/servecerts/pubkeys.py @@ -0,0 +1,156 @@ +from flask import ( + Blueprint, flash, g, redirect, render_template, request, url_for +) +from werkzeug.exceptions import abort + +from servecerts.auth import login_required +from servecerts.db import get_db + +import hashlib +import base64 + +#bp = Blueprint('pubkeys', __name__, url_prefix='/pubkeys') +bp = Blueprint('pubkeys', __name__, url_prefix='/pubkeys') + +@bp.route('/') +@login_required +def index(): + db = get_db() + pubkeys = db.execute( + 'SELECT' + ' p.id, key_name, fullname, ssh_pubkey, p.created, user_id, revoked, deleted' + ' FROM pubkeys p' + ' JOIN user u ON p.user_id = u.id' + ' ORDER BY deleted ASC, revoked ASC, p.created DESC' + ).fetchall() + users = db.execute( + 'SELECT * FROM user WHERE id = ?', (g.user['id'],) + ).fetchone() + return render_template('pubkeys/index.html', pubkeys=pubkeys, users=users) + +def get_pubkey(id, check_user=True): + pubkey = get_db().execute( + 'SELECT p.id, ssh_pubkey, key_name, user_id, fingerprint, deleted' + ' FROM pubkeys p JOIN user u ON p.user_id = u.id' + ' WHERE p.id = ?', (id,) + ).fetchone() + + if pubkey is None: + abort(404, "Pubkey id {0} does not exist.".format(id)) + + if check_user and pubkey['user_id'] != g.user['id']: + abort(403) + + return pubkey + +@bp.route('/create', methods=('GET', 'POST')) +@login_required +def create(): + if request.method == 'POST': + key_name = request.form['key_name'] + ssh_pubkey = request.form['ssh_pubkey'] + splitkey = ssh_pubkey.split(' ') + enc = splitkey.pop(0) + key = splitkey.pop(0) + comment = ' '.join(splitkey) + fingerprint = base64.b64encode(hashlib.sha256(base64.b64decode(key)).digest()).rstrip("=") + print fingerprint + + db = get_db() + ckfp = db.execute( + 'SELECT id, fingerprint, deleted FROM pubkeys WHERE fingerprint = ?', (fingerprint, ) + ).fetchone() + + if ckfp != None: + if ckfp['fingerprint'] == fingerprint: + if ckfp['deleted'] == 0: + error = "Key exists already" + else: + error = "Key was deleted before -> reactivate it again!" + flash(error) + return redirect(url_for('pubkeys.update', id=ckfp['id'])) + + if not key_name: + key_name = comment + error = None + + if not ssh_pubkey: + error = 'SSH-Pubkey is required.' + + if error is not None: + flash(error) + else: + #db = get_db() + db.execute( + 'INSERT INTO pubkeys (ssh_pubkey, key_name, user_id, fingerprint)' + ' VALUES (?, ?, ?, ?)', + (ssh_pubkey, key_name, g.user['id'], fingerprint) + ) + db.commit() + return redirect(url_for('pubkeys.index')) + + return render_template('pubkeys/create.html') + +@bp.route('//update', methods=('GET', 'POST')) +@login_required +def update(id): + pubkey = get_pubkey(id) + + if request.method == 'POST': + key_name = request.form['key_name'] + deleted = request.form.get('deleted') + error = None + + if not key_name: + error = 'Key-ID is required.' + + deleted = 0 if not deleted else 1 +# if not ssh_pubkey: +# error = "SSH-Pubkey is requred." + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + 'UPDATE pubkeys SET key_name = ?, deleted = ?' + ' WHERE id = ?', + (key_name, deleted, id) + ) + db.commit() + return redirect(url_for('pubkeys.index')) + + return render_template('pubkeys/update.html', pubkey=pubkey) + +@bp.route('//delete', methods=('GET',)) +@login_required +def delete(id): + get_pubkey(id) + db = get_db() + #db.execute('DELETE FROM pubkeys WHERE id = ?', (id,)) + db.execute('UPDATE pubkeys SET deleted = 1 WHERE id = ?', (id,)) + db.execute('UPDATE certificates SET deleted = 1 WHERE pubkey_id = ?', (id,)) + db.commit() + return redirect(url_for('pubkeys.index')) + +@bp.route('//deletefinal', methods=('GET',)) +@login_required +def deletefinal(id): + get_pubkey(id) + db = get_db() + db.execute('DELETE FROM pubkeys WHERE id = ?', (id,)) + #db.execute('UPDATE certificates SET deleted = 1 WHERE pubkey_id = ?', (id,)) + db.commit() + return redirect(url_for('pubkeys.index')) + +@bp.route('//revoke', methods=('POST', 'GET')) +@login_required +def revoke(id): + get_pubkey(id) + db = get_db() + db.execute( + 'UPDATE pubkeys SET revoked = 1' + ' WHERE id = ?', (id,) + ) + db.commit() + return redirect(url_for('pubkeys.index')) diff --git a/servecerts/schema.sql b/servecerts/schema.sql new file mode 100644 index 0000000..9d12978 --- /dev/null +++ b/servecerts/schema.sql @@ -0,0 +1,60 @@ +DROP TABLE IF EXISTS user; +DROP TABLE IF EXISTS pubkeys; +DROP TABLE IF EXISTS certificates; +DROP TABLE IF EXISTS settings; + +CREATE TABLE settings ( + id INTEGER PRIMARY KEY, + current_serialnumber INTEGER NOT NULL, + default_principals TEXT, + default_commands TEXT, + default_capabilities TEXT, + default_client_from TEXT, + current_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + fullname TEXT NOT NULL, + email TEXT NOT NULL, + principals TEXT, + commands TEXT DEFAULT username NOT NULL, + capabilities TEXT, + client_from TEXT, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE pubkeys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + key_name TEXT NOT NULL, + ssh_pubkey TEXT NOT NULL, + fingerprint TEXT, + revoked INTEGER DEFAULT 0 NOT NULL, + deleted INTEGER DEFAULT 0 NOT NULL, + userca INTEGER DEFAULT 0 NOT NULL, + hostca INTEGER DEFAULT 0 NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES user (id) +); + +CREATE TABLE certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pubkey_id INTEGER NOT NULL, + key_id TEXT NOT NULL, + serial INTEGER NOT NULL, + principals TEXT, + commands TEXT, + capabilities TEXT, + client_from TEXT, + revoked INTEGER DEFAULT 0 NOT NULL, + deleted INTEGER DEFAULT 0 NOT NULL, + valid_from TIMESTAMP, + valid_unitl TIMESTAMP, + expired INTEGER DEFAULT 0 NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (pubkey_id) REFERENCES pubkeys (id) +); + diff --git a/servecerts/static/style.css b/servecerts/static/style.css new file mode 100644 index 0000000..898ba0e --- /dev/null +++ b/servecerts/static/style.css @@ -0,0 +1,28 @@ +html { font-family: sans-serif; background: #eee; padding: 1rem; } +body { max-width: 960px; margin: 0 auto; background: white; } +h1 { font-family: serif; color: #377ba8; margin: 1rem 0; } +a { color: #377ba8; } +hr { border: none; border-top: 1px solid lightgray; } +nav { background: lightgray; display: flex; align-items: center; padding: 0 0.5rem; } +nav h1 { flex: auto; margin: 0; } +nav h1 a { text-decoration: none; padding: 0.25rem 0.5rem; } +nav ul { display: flex; list-style: none; margin: 0; padding: 0; } +nav ul li a, nav ul li span, header .action { display: block; padding: 0.5rem; } +.content { padding: 0 1rem 1rem; } +.content > header { border-bottom: 1px solid lightgray; display: flex; align-items: flex-end; } +.content > header h1 { flex: auto; margin: 1rem 0 0.25rem 0; } +.flash { margin: 1em 0; padding: 1em; background: #cae6f6; border: 1px solid #377ba8; } +.post > header { display: flex; align-items: flex-end; font-size: 0.85em; } +.post > header > div:first-of-type { flex: auto; } +.post > header h1 { font-size: 1.5em; margin-bottom: 0; } +.post .about { color: slategray; font-style: italic; } +.post .body { white-space: pre-line; } +.content:last-child { margin-bottom: 0; } +.content form { margin: 1em 0; display: flex; flex-direction: column; } +.content label { font-weight: bold; margin-bottom: 0.5em; } +.content input, .content textarea { margin-bottom: 1em; } +.content textarea { min-height: 12em; resize: vertical; } +.content div { overflow: hidden; word-wrap: break-word; } +.revoked, .deleted {text-decoration: line-through; color: slategray; font-style: italic;} +input.danger { color: #cc2f2e; } +input[type=submit] { align-self: start; min-width: 10em; } diff --git a/servecerts/templates/auth/login.html b/servecerts/templates/auth/login.html new file mode 100644 index 0000000..c4551f1 --- /dev/null +++ b/servecerts/templates/auth/login.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Log In{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+ {% endblock %} diff --git a/servecerts/templates/auth/register.html b/servecerts/templates/auth/register.html new file mode 100644 index 0000000..367a260 --- /dev/null +++ b/servecerts/templates/auth/register.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Register new User{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + + + + + +
+ {% endblock %} diff --git a/servecerts/templates/auth/update.html b/servecerts/templates/auth/update.html new file mode 100644 index 0000000..7a0a30e --- /dev/null +++ b/servecerts/templates/auth/update.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Edit User{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + + + + + + + + + + + + + +
+
+ +{% endblock %} diff --git a/servecerts/templates/base.html b/servecerts/templates/base.html new file mode 100644 index 0000000..3ed5c0c --- /dev/null +++ b/servecerts/templates/base.html @@ -0,0 +1,25 @@ + +{% block title %}{% endblock %} - Serve Certs + + +
+
+ {% block header %}{% endblock %} +
+ {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + {% block content %}{% endblock %} +
diff --git a/servecerts/templates/certificates/index.html b/servecerts/templates/certificates/index.html new file mode 100644 index 0000000..af77491 --- /dev/null +++ b/servecerts/templates/certificates/index.html @@ -0,0 +1,50 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %} + {% if g.user %} + Certificates for Pubkey {{ g.pubkeys['fullname'] }} + {% else %} + Certificates + {% endif %} + {% endblock %}

+ {% if g.user %} + New + {% endif %} +{% endblock %} + +{% block content %} + {% if g.user %} + {% for certificate in certificates %} +
+
+
+

Certificate ({{ certificate['id'] }}): {{ certificate['key_id'] }}

+
+ {% if g.user['id'] == pubkey['user_id'] %} + Revoke + {% endif %} +
+

+ Serialnumber: {{ certificate['serial'] }}{% if pubkey['revoked'] != 0 %} - revoked {% endif %}" + Validity duration: {{ certificate['valid_from'].strftime('%Y-%m-%d %H:%M') }} - {{ certificate['valid_until'].strftime('%Y-%m-%d %H:%M') }} + Principals: {{ certificate['principals'] }} + Valid Client IP: {{ certificate['from_ip'] }} + Allowed Command: {{ certificate['commands'] }} + Capabilities: {{ certificate['capabilities'] }} +

+

created on {{ certificate['created'].strftime('%Y-%m-%d') }}

+ +
+ +
+
+ {% if not loop.last %} +
+ {% endif %} + {% endfor %} + {% else %} +
To view pubkeys and certificates, please log in
+ {% endif %} +{% endblock %} + diff --git a/servecerts/templates/pubkeys/create.html b/servecerts/templates/pubkeys/create.html new file mode 100644 index 0000000..6a609ff --- /dev/null +++ b/servecerts/templates/pubkeys/create.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}New Public Key{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/servecerts/templates/pubkeys/index.html b/servecerts/templates/pubkeys/index.html new file mode 100644 index 0000000..dc0faab --- /dev/null +++ b/servecerts/templates/pubkeys/index.html @@ -0,0 +1,50 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %} + {% if g.user %} + Pubkeys for {{ g.user['fullname'] }} + {% else %} + Pubkeys + {% endif %} + {% endblock %}

+ {% if g.user %} + New + {% endif %} +{% endblock %} + +{% block content %} + {% if g.user %} + {% for pubkey in pubkeys %} + {% if pubkey['deleted'] == 0 %} +
+
+
+

{% if pubkey['revoked'] != 0 %}
revoked key
- {% endif %}({{ pubkey['id'] }}): {{ pubkey['key_name'] }}

+
+ {% if g.user['id'] == pubkey['user_id'] %} + Edit + {% if pubkey['deleted'] == 0 %} + Delete + {% endif %} + {% if pubkey['revoked'] == 0 %} + Revoke + {% endif %} + {% endif %} +
+
{{ request.form['ssh_pubkey'] or pubkey['ssh_pubkey'] }}
+

registered on {{ pubkey['created'].strftime('%Y-%m-%d') }}

+ +
+ +
+
+ {% if not loop.last %} +
+ {% endif %} + {% endif %} + {% endfor %} + {% else %} +
To view pubkeys and certificates, please log in
+ {% endif %} + {% endblock %} diff --git a/servecerts/templates/pubkeys/update.html b/servecerts/templates/pubkeys/update.html new file mode 100644 index 0000000..1f03fc3 --- /dev/null +++ b/servecerts/templates/pubkeys/update.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Edit "{{ pubkey['fingerprint'] }}"{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + + + + +
+
+
    +
  • + +
    +
+{% endblock %}