diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py index bd4a357..58ad0f0 100644 --- a/certidude/api/__init__.py +++ b/certidude/api/__init__.py @@ -190,6 +190,7 @@ def certidude_app(): from .tag import TagResource, TagDetailResource from .attrib import AttributeResource from .bootstrap import BootstrapResource + from .token import TokenResource app = falcon.API(middleware=NormalizeMiddleware()) app.req_options.auto_parse_form_urlencoded = True @@ -202,7 +203,9 @@ def certidude_app(): app.add_route("/api/request/{cn}/", RequestDetailResource()) app.add_route("/api/request/", RequestListResource()) app.add_route("/api/", SessionResource()) - app.add_route("/api/bootstrap/", BootstrapResource()) + + if config.BUNDLE_FORMAT and config.USER_ENROLLMENT_ALLOWED: + app.add_route("/api/token/", TokenResource()) # Extended attributes for scripting etc. app.add_route("/api/signed/{cn}/attr/", AttributeResource()) @@ -217,8 +220,7 @@ def certidude_app(): # Gateways can submit leases via this API call app.add_route("/api/lease/", LeaseResource()) - # Optional user enrollment API call - if config.USER_ENROLLMENT_ALLOWED: - app.add_route("/api/bundle/", BundleResource()) + # Bootstrap resource + app.add_route("/api/bootstrap/", BootstrapResource()) return app diff --git a/certidude/api/bundle.py b/certidude/api/bundle.py deleted file mode 100644 index db0a35f..0000000 --- a/certidude/api/bundle.py +++ /dev/null @@ -1,44 +0,0 @@ -import logging -import hashlib -from certidude import config, authority -from certidude.auth import login_required - -logger = logging.getLogger(__name__) - -KEYWORDS = ( - (u"Android", u"android"), - (u"iPhone", u"iphone"), - (u"iPad", u"ipad"), - (u"Ubuntu", u"ubuntu"), - (u"Fedora", u"fedora"), - (u"Linux", u"linux"), - (u"Macintosh", u"mac"), -) - -class BundleResource(object): - @login_required - def on_get(self, req, resp): - common_name = req.context["user"].name - if config.USER_MULTIPLE_CERTIFICATES: - for key, value in KEYWORDS: - if key in req.user_agent: - device_identifier = value - break - else: - device_identifier = u"unknown-device" - common_name = u"%s@%s-%s" % (common_name, device_identifier, \ - hashlib.sha256(req.user_agent).hexdigest()[:8]) - - logger.info(u"Signing bundle %s for %s", common_name, req.context.get("user")) - if config.BUNDLE_FORMAT == "p12": - resp.set_header("Content-Type", "application/x-pkcs12") - resp.set_header("Content-Disposition", "attachment; filename=%s.p12" % common_name.encode("ascii")) - resp.body, cert = authority.generate_pkcs12_bundle(common_name, - owner=req.context.get("user")) - elif config.BUNDLE_FORMAT == "ovpn": - resp.set_header("Content-Type", "application/x-openvpn") - resp.set_header("Content-Disposition", "attachment; filename=%s.ovpn" % common_name.encode("ascii")) - resp.body, cert = authority.generate_ovpn_bundle(common_name, - owner=req.context.get("user")) - else: - raise ValueError("Unknown bundle format %s" % config.BUNDLE_FORMAT) diff --git a/certidude/api/token.py b/certidude/api/token.py new file mode 100644 index 0000000..34c9c90 --- /dev/null +++ b/certidude/api/token.py @@ -0,0 +1,94 @@ +import click +import logging +import hashlib +import random +import string +from datetime import datetime +from time import time +from certidude import mailer +from certidude.user import User +from certidude import config, authority +from certidude.auth import login_required, authorize_admin + +logger = logging.getLogger(__name__) + +chars = string.ascii_letters + string.digits + '!@#$%^&*()' +SECRET = ''.join(random.choice(chars) for i in range(32)) + +click.echo("Token secret: %s" % SECRET) + + +KEYWORDS = ( + (u"Android", u"android"), + (u"iPhone", u"iphone"), + (u"iPad", u"ipad"), + (u"Ubuntu", u"ubuntu"), + (u"Fedora", u"fedora"), + (u"Linux", u"linux"), + (u"Macintosh", u"mac"), +) + +class TokenResource(object): + def on_get(self, req, resp): + # Consume token + now = time() + timestamp = req.get_param_as_int("t", required=True) + username = req.get_param("u", required=True) + user = User.objects.get(username) + csum = hashlib.sha256() + csum.update(SECRET) + csum.update(username) + csum.update(str(timestamp)) + + if csum.hexdigest() != req.get_param("c", required=True): + raise # TODO + if now < timestamp: + raise # Token not valid yet + if now > timestamp + config.TOKEN_LIFETIME: + raise # token expired + # At this point consider token to be legitimate + + common_name = username + if config.USER_MULTIPLE_CERTIFICATES: + for key, value in KEYWORDS: + if key in req.user_agent: + device_identifier = value + break + else: + device_identifier = u"unknown-device" + common_name = u"%s@%s-%s" % (common_name, device_identifier, \ + hashlib.sha256(req.user_agent).hexdigest()[:8]) + + logger.info(u"Signing bundle %s for %s", common_name, req.context.get("user")) + if config.BUNDLE_FORMAT == "p12": + resp.set_header("Content-Type", "application/x-pkcs12") + resp.set_header("Content-Disposition", "attachment; filename=%s.p12" % common_name.encode("ascii")) + resp.body, cert = authority.generate_pkcs12_bundle(common_name, + owner=req.context.get("user")) + elif config.BUNDLE_FORMAT == "ovpn": + resp.set_header("Content-Type", "application/x-openvpn") + resp.set_header("Content-Disposition", "attachment; filename=%s.ovpn" % common_name.encode("ascii")) + resp.body, cert = authority.generate_ovpn_bundle(common_name, + owner=req.context.get("user")) + else: + raise ValueError("Unknown bundle format %s" % config.BUNDLE_FORMAT) + + + @login_required + @authorize_admin + def on_post(self, req, resp): + # Generate token + issuer = req.context.get("user") + username = req.get_param("user", required=True) + user = User.objects.get(username) + timestamp = int(time()) + csum = hashlib.sha256() + csum.update(SECRET) + csum.update(username) + csum.update(str(timestamp)) + args = "u=%s&t=%d&c=%s" % (username, timestamp, csum.hexdigest()) + token_created = datetime.utcfromtimestamp(timestamp) + token_expires = datetime.utcfromtimestamp(timestamp + config.TOKEN_LIFETIME) + context = globals() + context.update(locals()) + mailer.send("token.md", to=user, **context) diff --git a/certidude/config.py b/certidude/config.py index 3ed18c8..e1b7a95 100644 --- a/certidude/config.py +++ b/certidude/config.py @@ -44,8 +44,6 @@ EXPIRED_DIR = cp.get("authority", "expired dir") MAILER_NAME = cp.get("mailer", "name") MAILER_ADDRESS = cp.get("mailer", "address") -BUNDLE_FORMAT = cp.get("bundle", "format") -OPENVPN_PROFILE_TEMPLATE = cp.get("bundle", "openvpn profile template") BOOTSTRAP_TEMPLATE = cp.get("bootstrap", "services template") MACHINE_ENROLLMENT_ALLOWED = { @@ -96,4 +94,10 @@ else: TAG_TYPES = [j.split("/", 1) + [cp.get("tagging", j)] for j in cp.options("tagging")] +# Tokens +BUNDLE_FORMAT = cp.get("token", "format") +OPENVPN_PROFILE_TEMPLATE = cp.get("token", "openvpn profile template") +TOKEN_URL = cp.get("token", "url") +TOKEN_LIFETIME = cp.getint("token", "lifetime") + # TODO: Check if we don't have base or servers diff --git a/certidude/mailer.py b/certidude/mailer.py index 6bea074..875a6f4 100644 --- a/certidude/mailer.py +++ b/certidude/mailer.py @@ -12,16 +12,17 @@ from urlparse import urlparse env = Environment(loader=PackageLoader("certidude", "templates/mail")) -def send(template, to=None, attachments=(), **context): +def send(template, to=None, include_admins=True, attachments=(), **context): from certidude import authority, config if not config.MAILER_ADDRESS: # Mailbox disabled, don't send e-mail return - recipients = u", ".join([unicode(j) for j in User.objects.filter_admins()]) - + recipients = () + if include_admins: + recipients = tuple(User.objects.filter_admins()) if to: - recipients = to + u", " + recipients + recipients = (to,) + recipients click.echo("Sending e-mail %s to %s" % (template, recipients)) @@ -29,12 +30,12 @@ def send(template, to=None, attachments=(), **context): html = markdown(text) msg = MIMEMultipart("alternative") - msg["Subject"] = subject + msg["Subject"] = subject.encode("utf-8") msg["From"] = "%s <%s>" % (config.MAILER_NAME, config.MAILER_ADDRESS) - msg["To"] = recipients + msg["To"] = ", ".join([unicode(j) for j in recipients]).encode("utf-8") - part1 = MIMEText(text, "plain") - part2 = MIMEText(html, "html") + part1 = MIMEText(text.encode("utf-8"), "plain", "utf-8") + part2 = MIMEText(html.encode("utf-8"), "html", "utf-8") msg.attach(part1) msg.attach(part2) @@ -44,6 +45,7 @@ def send(template, to=None, attachments=(), **context): part.add_header('Content-Disposition', 'attachment', filename=suggested_filename) part.set_payload(attachment) msg.attach(part) + click.echo("Sending to: %s" % msg["to"]) conn = smtplib.SMTP("localhost") - conn.sendmail(config.MAILER_ADDRESS, recipients, msg.as_string()) + conn.sendmail(config.MAILER_ADDRESS, [u.mail for u in recipients], msg.as_string()) diff --git a/certidude/templates/mail/token.md b/certidude/templates/mail/token.md index ac702e6..4a1eccb 100644 --- a/certidude/templates/mail/token.md +++ b/certidude/templates/mail/token.md @@ -1,5 +1,18 @@ -Stored request {{ common_name }} +Token for setting up VPN -This is simply to notify that certificate signing request for {{ common_name }} -was stored. You may log in with a certificate authority administration account to sign it. +{{ issuer }} has provided {{ user }} a token for retrieving +profile from the link below. + +{% if config.BUNDLE_FORMAT == "ovpn" %} +To set up OpenVPN for your device: + +* for Android install [OpenVPN Connect](https://play.google.com/store/apps/details?id=de.blinkt.openvpn) app. After importing the OpenVPN profile in OpenVPN application and delete the downloaded .ovpn file. +* for iOS device install [OpenVPN Connect](https://itunes.apple.com/us/app/openvpn-connect/id590379981) app. Tap on the token URL below, it should be automatically opened with OpenVPN Connect app. Tap connect to establish connection. +* for Mac OS X download [Tunnelblick](https://tunnelblick.net/downloads.html) +* for Ubuntu and Fedora install OpenVPN plugin for NetworkManager. Open network settings, add connection and select "Import from file ...". Supply the file retrieved via the token URL below. +* for Windows you need to install OpenVPN community edition from [here](https://swupdate.openvpn.org/community/releases/openvpn-install-2.3.14-I601-x86_64.exe) and TAP driver from [here](https://swupdate.openvpn.org/community/releases/tap-windows-9.21.2.exe) +{% endif %} + +Click [here]({{ config.TOKEN_URL }}?{{ args }}) to claim the token. +Token is usable until {{ token_expires }} (UTC). diff --git a/certidude/templates/server/server.conf b/certidude/templates/server/server.conf index 287df1b..532e3b7 100644 --- a/certidude/templates/server/server.conf +++ b/certidude/templates/server/server.conf @@ -141,14 +141,6 @@ name = Certificate management address = ;address = certificates@example.com -[bundle] -format = p12 -;format = ovpn - -# Template for OpenVPN profile, copy certidude/templates/openvpn-client.conf -# to /etc/certidude/ and make modifications as necessary -openvpn profile template = {{ template_path }}/openvpn-client.conf - [tagging] owner/string = Owner location/string = Location @@ -160,3 +152,18 @@ other/ = Other # Services template is rendered on certidude server with relevant variables and # placed to /etc/certidude/services.conf on the client services template = {{ template_path }}/bootstrap.conf + +[token] +# Token mechanism allows authority administrator to send invites for users. +# Token URL could be for example exposed on the internet via proxypass. +url = http://{{ common_name }}/api/token +lifetime = 3600 + +# Profile format, uncomment specific one to enable token mechanism +format = +;format = p12 +;format = ovpn + +# Template for OpenVPN profile, copy certidude/templates/openvpn-client.conf +# to /etc/certidude/ and make modifications as necessary +openvpn profile template = {{ template_path }}/openvpn-client.conf