diff --git a/README.md b/README.md index 9e78093..1525bcc 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,27 @@ _Being an adminstrative and user-signup tool for [https://tilde.town]_. -## Features +## Features, present and future - * User signup form (✓) - * with client-side key generation (only server-side key **validation** at this point) - * Guestbook (✓) - * Helpdesk (✓) - * User account management (admin only) + * (✓) User signup form + * eventually with client-side key generation (only server-side key + **validation** at this point) + * (✓) Guestbook + * (✓) Helpdesk + * (✓) User account management * Start/stop services * Cost reporting using AWS * Status monitoring -## Libraries +## Requirements - * Django 1.10 - * Python 3.4+ + * Python 3.5+ + * PostgreSQL 9+ + +## Installation / setup + +Refer to the rather rough [serversetup.md](server setup guide). just ask +~vilmibm though if you want to set this up. ## Authors diff --git a/scripts/create_keyfile.py b/scripts/create_keyfile.py new file mode 100755 index 0000000..33be19b --- /dev/null +++ b/scripts/create_keyfile.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""this script allows django to add public keys for a user. it's in its own +script so that a specific command can be added to the ttadmin user's sudoers +file.""" +import sys + +KEYFILE_PATH = '/home/{}/.ssh/authorized_keys2' + + +def main(argv): + username = argv[1] + with open(KEYFILE_PATH.format(username), 'w') as f: + f.write(sys.stdin.read()) + + +if __name__ == '__main__': + exit(main(sys.argv)) diff --git a/serversetup.md b/serversetup.md index aef158e..8a87e58 100644 --- a/serversetup.md +++ b/serversetup.md @@ -5,7 +5,7 @@ * autoconf? * python3-dev * postgresql-server-dev-9.5 -* python 3.4+ +* python 3.5+ * postgresql * virtualenv * nginx @@ -14,9 +14,11 @@ * create ttadmin user * ttadmin db user (or just rely on ident..?) / database created +* copy `create_keyfile.py` from `scripts/` and put it in `/opt/bin/`. +* `chmod o+x /opt/bin/create_keyfile.py`` * add to sudoers: - ttadmin ALL=(ALL)NOPASSWD:/usr/sbin/adduser,/usr/sbin/deluser,/usr/sbin/delgroup + ttadmin ALL=(ALL)NOPASSWD:/usr/sbin/adduser,/bin/mkdir,/opt/bin/create_keyfile.py * have virtualenv with python 3.5+ ready, install tildetown-admin package into it * run django app as wsgi container through gunicorn as the ttadmin user with venv active diff --git a/setup.py b/setup.py index 68f1f0c..f1b0d30 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup setup( name='tildetown-admin', - version='1.0.0', + version='1.1.0', description='administrative webapp for tilde.town', url='https://github.com/nathanielksmith/prosaic', author='vilmibm shaksfrpease', @@ -18,6 +18,7 @@ setup( install_requires = ['Django==1.10.2', 'sshpubkeys==2.2.0', 'psycopg2==2.6.2', - 'requests==2.12.5'], + 'requests==2.12.5', + 'gunicorn==19.6.0'], include_package_data = True, ) diff --git a/ttadmin/common/static/common/vt323.ttf b/ttadmin/common/static/common/vt323.ttf new file mode 100644 index 0000000..afa6909 Binary files /dev/null and b/ttadmin/common/static/common/vt323.ttf differ diff --git a/ttadmin/users/models.py b/ttadmin/users/models.py index af1b6cc..308a9ce 100644 --- a/ttadmin/users/models.py +++ b/ttadmin/users/models.py @@ -1,4 +1,7 @@ +import os import re +from subprocess import run, CalledProcessError +from tempfile import TemporaryFile from django.db.models import Model from django.db.models.signals import pre_save @@ -15,6 +18,11 @@ SSH_TYPE_CHOICES = ( ('ssh-dss', 'ssh-dss',), ) +KEYFILE_HEADER = """########## GREETINGS! ########## +# Hi! This file is automatically managed by tilde.town. You +# probably shouldn't change it. If you want to add more public keys that's +# totally fine: you can put them in ~/.ssh/authorized_keys""" + class Townie(User): """Both an almost normal Django User as well as an abstraction over a @@ -44,6 +52,64 @@ class Townie(User): self.username, self.email)) + # managing concrete system state + + def create_on_disk(self): + """A VERY NOT IDEMPOTENT create function. Originally, I had ambitions + to have this be idempotent and able to incrementally update a user as + needed, but decided that was overkill for now.""" + assert(self.reviewed) + dot_ssh_path = '/home/{}/.ssh'.format(self.username) + + _guarded_run(['sudo', + 'adduser', + '--quiet', + '--shell={}'.format(self.shell), + '--gecos="{}"'.format(self.displayname), + '--disabled-password', + self.username,]) + + # Create .ssh + _guarded_run(['sudo', + '--user={}'.format(self.username), + 'mkdir', + dot_ssh_path]) + + # Write out authorized_keys file + # Why is this a call out to a python script? There's no secure way with + # sudoers to allow this code to write to a file; if this code was to be + # compromised, the ability to write arbitrary files with sudo is a TKO. + # By putting the ssh key file creation into its own script, we can just + # give sudo access for that one command to this code. + # + # We could put the other stuff from here into that script and then only + # grant sudo for the script, but then we're moving code out of this + # virtual-env contained, maintainable thing into a script. it's my + # preference to have the script be as minimal as possible. + with TemporaryFile(dir="/tmp") as fp: + fp.write(self.generate_authorized_keys().encode('utf-8')) + fp.seek(0) + _guarded_run(['sudo', + '--user={}'.format(self.username), + '/opt/bin/create_keyfile.py', + self.username], + stdin=fp, + ) + + def generate_authorized_keys(self): + """returns a string suitable for writing out to an authorized_keys + file""" + content = KEYFILE_HEADER + pubkeys = Pubkey.objects.filter(townie=self) + for key in pubkeys: + if key.key.startswith('ssh-'): + content += '\n{}'.format(key.key) + else: + content += '\n{} {}'.format(key.key_type, key.key) + + return content + + class Pubkey(Model): key_type = CharField(max_length=50, blank=False, @@ -61,4 +127,65 @@ def on_townie_pre_save(sender, instance, **kwargs): return if not existing[0].reviewed and instance.reviewed == True: + instance.create_on_disk() instance.send_welcome_email() + +def _guarded_run(cmd_args, **run_args): + try: + run(cmd_args, + check=True, + **run_args) + except CalledProcessError as e: + Ticket.objects.create(name='system', + email='root@tilde.town', + issue_type='other', + issue_text='error while running {}: {}'.format( + cmd_args, e)) + + +# development notes! + +# what the puppet module does: +# * creates user account +# * creates home directory +# * creates authorized_keys2 +# * adds user to group 'tilde' (why?) +# * sets shell +# * creates .ssh directory +# * creates .irssi directory +# * creates templatized .irssi config file (irssi isn't even default anymore...) +# * creates hardcoded .twurlrc file (why not skel?) +# +# some of this stuff is pointless and the actually required stuff is: +# * create user account (useradd) +# * create home dir (useradd) +# * create .ssh/authorized_keys2 (need functions for this +# * set shell (chsh) +# * create .twurlrc (just use /etc/skel) + +# other things to consider: +# * what happens when a user wants their name changed? +# * it looks like usermod -l and a mv of the home dir can change a user's username. +# * would hook this into the pre_save signal to note a username change +# * what happens when a user is marked as not reviewed? +# * does this signal user deletion? Or does literal Townie deletion signal +# "needs to be removed from disk"? I think it makes the most sense for the +# latter to imply full user deletion. +# * I honestly can't even think of a reason to revert a user to "not reviewed" +# and perhaps it's best to just not make that possible. for now, though, I +# think I can ignore it. +# * what happens when a user needs to be banned? +# * the Townie should be deleted via post_delete signal +# * what are things about a user that might change in django and require changes on disk? +# * username +# * displayname (only if i start using this?) +# * ssh key +# +# how should this code be structured? +# * within the Townie model, hardcoded +# * outside the Townie model, procedurally +# * within an abstract class +# +# for now my gut says to implement stuff hardcoded in the Townie class but with +# an eye towards generalizing the pattern in some base class for other +# resources as needed. diff --git a/ttadmin/users/static/users/base64url.js b/ttadmin/users/static/users/base64url.js new file mode 100644 index 0000000..8a35147 --- /dev/null +++ b/ttadmin/users/static/users/base64url.js @@ -0,0 +1,28 @@ +/*jslint browser: true, sloppy: true */ +//adapted from https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-08#appendix-C + +function base64urlEncode(arg) { + var s = window.btoa(arg); // Regular base64 encoder + s = s.split('=')[0]; // Remove any trailing '='s + s = s.replace(/\+/g, '-'); // 62nd char of encoding + s = s.replace(/\//g, '_'); // 63rd char of encoding + return s; +} + +function base64urlDecode(s) { + s = s.replace(/-/g, '+'); // 62nd char of encoding + s = s.replace(/_/g, '/'); // 63rd char of encoding + switch (s.length % 4) { // Pad with trailing '='s + case 0: // No pad chars in this case + break; + case 2: // Two pad chars + s += "=="; + break; + case 3: // One pad char + s += "="; + break; + default: + throw "Illegal base64url string!"; + } + return window.atob(s); // Standard base64 decoder +} diff --git a/ttadmin/users/static/users/js-keygen-ui.js b/ttadmin/users/static/users/js-keygen-ui.js new file mode 100644 index 0000000..3e2a894 --- /dev/null +++ b/ttadmin/users/static/users/js-keygen-ui.js @@ -0,0 +1,52 @@ +/*jslint browser: true, sloppy: true, vars: true, indent: 2*/ +var console, generateKeyPair; + +function copy(id) { + return function () { + var ta = document.querySelector(id); + ta.focus(); + ta.select(); + try { + var successful = document.execCommand('copy'); + var msg = successful ? 'successful' : 'unsuccessful'; + console.log('Copy key command was ' + msg); + } catch (err) { + console.log('Oops, unable to copy'); + } + window.getSelection().removeAllRanges(); + ta.blur(); + }; +} + +function buildHref(data) { + return "data:application/octet-stream;charset=utf-8;base64," + window.btoa(data); +} + +document.addEventListener("DOMContentLoaded", function (event) { + document.querySelector('#savePrivate').addEventListener('click', function (event) { + document.querySelector('a#private').click(); + }); + document.querySelector('#copyPrivate').addEventListener('click', copy('#privateKey')); + document.querySelector('#savePublic').addEventListener('click', function (event) { + document.querySelector('a#public').click(); + }); + document.querySelector('#copyPublic').addEventListener('click', copy('#publicKey')); + + document.querySelector('#generate').addEventListener('click', function (event) { + var name = document.querySelector('#name').value || "name"; + document.querySelector('a#private').setAttribute("download", name + "_rsa"); + document.querySelector('a#public').setAttribute("download", name + "_rsa.pub"); + + var alg = document.querySelector('#alg').value || "RSASSA-PKCS1-v1_5"; + var size = parseInt(document.querySelector('#size').value || "2048", 10); + generateKeyPair(alg, size, name).then(function (keys) { + document.querySelector('#private').setAttribute("href", buildHref(keys[0])); + document.querySelector('#public').setAttribute("href", buildHref(keys[1])); + document.querySelector('#privateKey').textContent = keys[0]; + document.querySelector('#publicKey').textContent = keys[1]; + document.querySelector('#result').style.display = "block"; + }).catch(function (err) { + console.error(err); + }); + }); +}); diff --git a/ttadmin/users/static/users/js-keygen.js b/ttadmin/users/static/users/js-keygen.js new file mode 100644 index 0000000..17e442b --- /dev/null +++ b/ttadmin/users/static/users/js-keygen.js @@ -0,0 +1,53 @@ +/*jslint browser: true, devel: true, sloppy: true, vars: true*/ +/*globals Uint8Array, Promise */ +var extractable = true; +var encodePrivateKey, encodePublicKey; + +function wrap(text, len) { + var length = len || 72, i, result = ""; + for (i = 0; i < text.length; i += length) { + result += text.slice(i, i + length) + "\n"; + } + return result; +} + +function rsaPrivateKey(key) { + return "-----BEGIN RSA PRIVATE KEY-----\n" + key + "-----END RSA PRIVATE KEY-----"; +} + +function arrayBufferToBase64(buffer) { + var binary = '', i; + var bytes = new Uint8Array(buffer); + var len = bytes.byteLength; + for (i = 0; i < len; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); +} + +function generateKeyPair(alg, size, name) { + return window.crypto.subtle.generateKey({ + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, //can be 1024, 2048, or 4096 + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: {name: "SHA-1"} //can be "SHA-1", "SHA-256", "SHA-384", or "SHA-512" + }, + extractable, + ["sign", "verify"] + ).then(function (key) { + + var privateKey = window.crypto.subtle.exportKey( + "jwk", + key.privateKey + ).then(encodePrivateKey).then(wrap).then(rsaPrivateKey); + + var publicKey = window.crypto.subtle.exportKey( + "jwk", + key.publicKey + ).then(function (jwk) { + return encodePublicKey(jwk, name); + }); + + return Promise.all([privateKey, publicKey]); + }); +} diff --git a/ttadmin/users/static/users/ssh-util.js b/ttadmin/users/static/users/ssh-util.js new file mode 100644 index 0000000..bf261cb --- /dev/null +++ b/ttadmin/users/static/users/ssh-util.js @@ -0,0 +1,122 @@ +/*jslint browser: true, devel: true, bitwise: true, sloppy: true, vars: true*/ + +var base64urlDecode; + +function arrayToString(a) { + return String.fromCharCode.apply(null, a); +} + +function stringToArray(s) { + return s.split('').map(function (c) { + return c.charCodeAt(); + }); +} + +function base64urlToArray(s) { + return stringToArray(base64urlDecode(s)); +} + +function pemToArray(pem) { + return stringToArray(window.atob(pem)); +} + +function arrayToPem(a) { + return window.btoa(a.map(function (c) { + return String.fromCharCode(c); + }).join('')); +} + +function arrayToLen(a) { + var result = 0, i; + for (i = 0; i < a.length; i += 1) { + result = result * 256 + a[i]; + } + return result; +} + +function integerToOctet(n) { + var result = []; + for (true; n > 0; n = n >> 8) { + result.push(n & 0xFF); + } + return result.reverse(); +} + +function lenToArray(n) { + var oct = integerToOctet(n), i; + for (i = oct.length; i < 4; i += 1) { + oct.unshift(0); + } + return oct; +} + +function decodePublicKey(s) { + var split = s.split(" "); + var prefix = split[0]; + if (prefix !== "ssh-rsa") { + throw ("Unknown prefix:" + prefix); + } + var buffer = pemToArray(split[1]); + var nameLen = arrayToLen(buffer.splice(0, 4)); + var type = arrayToString(buffer.splice(0, nameLen)); + if (type !== "ssh-rsa") { + throw ("Unknown key type:" + type); + } + var exponentLen = arrayToLen(buffer.splice(0, 4)); + var exponent = buffer.splice(0, exponentLen); + var keyLen = arrayToLen(buffer.splice(0, 4)); + var key = buffer.splice(0, keyLen); + return {type: type, exponent: exponent, key: key, name: split[2]}; +} + +function checkHighestBit(v) { + if (v[0] >> 7 === 1) { // add leading zero if first bit is set + v.unshift(0); + } + return v; +} + +function jwkToInternal(jwk) { + return { + type: "ssh-rsa", + exponent: checkHighestBit(stringToArray(base64urlDecode(jwk.e))), + name: "name", + key: checkHighestBit(stringToArray(base64urlDecode(jwk.n))) + }; +} + +function encodePublicKey(jwk, name) { + var k = jwkToInternal(jwk); + k.name = name; + var keyLenA = lenToArray(k.key.length); + var exponentLenA = lenToArray(k.exponent.length); + var typeLenA = lenToArray(k.type.length); + var array = [].concat(typeLenA, stringToArray(k.type), exponentLenA, k.exponent, keyLenA, k.key); + var encoding = arrayToPem(array); + return k.type + " " + encoding + " " + k.name; +} + +function asnEncodeLen(n) { + var result = []; + if (n >> 7) { + result = integerToOctet(n); + result.unshift(0x80 + result.length); + } else { + result.push(n); + } + return result; +} + +function encodePrivateKey(jwk) { + var order = ["n", "e", "d", "p", "q", "dp", "dq", "qi"]; + var list = order.map(function (prop) { + var v = checkHighestBit(stringToArray(base64urlDecode(jwk[prop]))); + var len = asnEncodeLen(v.length); + return [0x02].concat(len, v); // int tag is 0x02 + }); + var seq = [0x02, 0x01, 0x00]; // extra seq for SSH + seq = seq.concat.apply(seq, list); + var len = asnEncodeLen(seq.length); + var a = [0x30].concat(len, seq); // seq is 0x30 + return arrayToPem(a); +} diff --git a/ttadmin/users/templates/users/keymachine.html b/ttadmin/users/templates/users/keymachine.html new file mode 100644 index 0000000..2e03812 --- /dev/null +++ b/ttadmin/users/templates/users/keymachine.html @@ -0,0 +1,100 @@ +{% load static %} + + + + + + tilde.town magic key machine + + + + + + + +

THE TILDE.TOWN MAGIC KEY MACHINE

+
this page will make you an SSH keypair. a keypair + consists of a public key and a private key. they're + actually really long numbers that are used in some insane math which all + boils down to one really cool fact:
+
+ + + when used together, your keypair lets your computer talk, in + perfect secrecy, with another computer. + + +
+ +
+

I'm a public key!

+ +
+ +
+
+

I'm a private key!
KEEP ME SECRET, PLEASE

+ +
+ +
+ + + + diff --git a/ttadmin/users/templates/users/signup.html b/ttadmin/users/templates/users/signup.html index 3ba1497..75e77ea 100644 --- a/ttadmin/users/templates/users/signup.html +++ b/ttadmin/users/templates/users/signup.html @@ -29,7 +29,7 @@

If you have any problems with this process, please send a tweet to @tildetown - or file a helpdesk ticket. + or file a helpdesk ticket.

diff --git a/ttadmin/users/urls.py b/ttadmin/users/urls.py index b11c6d3..5832263 100644 --- a/ttadmin/users/urls.py +++ b/ttadmin/users/urls.py @@ -1,9 +1,10 @@ from django.conf.urls import url -from .views import SignupView, ThanksView +from .views import SignupView, ThanksView, KeyMachineView app_name = 'users' urlpatterns = [ url(r'^signup/?$', SignupView.as_view(), name='signup'), url(r'^thanks/?$', ThanksView.as_view(), name='thanks'), + url(r'^keymachine/?$', KeyMachineView.as_view(), name='keymachine'), ] diff --git a/ttadmin/users/views.py b/ttadmin/users/views.py index 4f4b5f4..f5cbe39 100644 --- a/ttadmin/users/views.py +++ b/ttadmin/users/views.py @@ -34,3 +34,6 @@ class SignupView(FormView): class ThanksView(TemplateView): template_name = 'users/thanks.html' + +class KeyMachineView(TemplateView): + template_name = 'users/keymachine.html'