mirror of
				https://github.com/laurivosandi/certidude
				synced 2025-10-31 09:29:13 +00:00 
			
		
		
		
	Add token based auth for profiles
This commit is contained in:
		| @@ -190,6 +190,7 @@ def certidude_app(): | |||||||
|     from .tag import TagResource, TagDetailResource |     from .tag import TagResource, TagDetailResource | ||||||
|     from .attrib import AttributeResource |     from .attrib import AttributeResource | ||||||
|     from .bootstrap import BootstrapResource |     from .bootstrap import BootstrapResource | ||||||
|  |     from .token import TokenResource | ||||||
|  |  | ||||||
|     app = falcon.API(middleware=NormalizeMiddleware()) |     app = falcon.API(middleware=NormalizeMiddleware()) | ||||||
|     app.req_options.auto_parse_form_urlencoded = True |     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/{cn}/", RequestDetailResource()) | ||||||
|     app.add_route("/api/request/", RequestListResource()) |     app.add_route("/api/request/", RequestListResource()) | ||||||
|     app.add_route("/api/", SessionResource()) |     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. |     # Extended attributes for scripting etc. | ||||||
|     app.add_route("/api/signed/{cn}/attr/", AttributeResource()) |     app.add_route("/api/signed/{cn}/attr/", AttributeResource()) | ||||||
| @@ -217,8 +220,7 @@ def certidude_app(): | |||||||
|     # Gateways can submit leases via this API call |     # Gateways can submit leases via this API call | ||||||
|     app.add_route("/api/lease/", LeaseResource()) |     app.add_route("/api/lease/", LeaseResource()) | ||||||
|  |  | ||||||
|     # Optional user enrollment API call |     # Bootstrap resource | ||||||
|     if config.USER_ENROLLMENT_ALLOWED: |     app.add_route("/api/bootstrap/", BootstrapResource()) | ||||||
|         app.add_route("/api/bundle/", BundleResource()) |  | ||||||
|  |  | ||||||
|     return app |     return app | ||||||
|   | |||||||
| @@ -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) |  | ||||||
							
								
								
									
										94
									
								
								certidude/api/token.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								certidude/api/token.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
| @@ -44,8 +44,6 @@ EXPIRED_DIR = cp.get("authority", "expired dir") | |||||||
| MAILER_NAME = cp.get("mailer", "name") | MAILER_NAME = cp.get("mailer", "name") | ||||||
| MAILER_ADDRESS = cp.get("mailer", "address") | 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") | BOOTSTRAP_TEMPLATE = cp.get("bootstrap", "services template") | ||||||
|  |  | ||||||
| MACHINE_ENROLLMENT_ALLOWED = { | MACHINE_ENROLLMENT_ALLOWED = { | ||||||
| @@ -96,4 +94,10 @@ else: | |||||||
|  |  | ||||||
| TAG_TYPES = [j.split("/", 1) + [cp.get("tagging", j)] for j in cp.options("tagging")] | 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 | # TODO: Check if we don't have base or servers | ||||||
|   | |||||||
| @@ -12,16 +12,17 @@ from urlparse import urlparse | |||||||
|  |  | ||||||
| env = Environment(loader=PackageLoader("certidude", "templates/mail")) | 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 |     from certidude import authority, config | ||||||
|     if not config.MAILER_ADDRESS: |     if not config.MAILER_ADDRESS: | ||||||
|         # Mailbox disabled, don't send e-mail |         # Mailbox disabled, don't send e-mail | ||||||
|         return |         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: |     if to: | ||||||
|         recipients = to + u", " + recipients |         recipients = (to,) + recipients | ||||||
|  |  | ||||||
|     click.echo("Sending e-mail %s to %s" % (template, 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) |     html = markdown(text) | ||||||
|  |  | ||||||
|     msg = MIMEMultipart("alternative") |     msg = MIMEMultipart("alternative") | ||||||
|     msg["Subject"] = subject |     msg["Subject"] = subject.encode("utf-8") | ||||||
|     msg["From"] = "%s <%s>" % (config.MAILER_NAME, config.MAILER_ADDRESS) |     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") |     part1 = MIMEText(text.encode("utf-8"), "plain", "utf-8") | ||||||
|     part2 = MIMEText(html, "html") |     part2 = MIMEText(html.encode("utf-8"), "html", "utf-8") | ||||||
|  |  | ||||||
|     msg.attach(part1) |     msg.attach(part1) | ||||||
|     msg.attach(part2) |     msg.attach(part2) | ||||||
| @@ -44,6 +45,7 @@ def send(template, to=None, attachments=(), **context): | |||||||
|         part.add_header('Content-Disposition', 'attachment', filename=suggested_filename) |         part.add_header('Content-Disposition', 'attachment', filename=suggested_filename) | ||||||
|         part.set_payload(attachment) |         part.set_payload(attachment) | ||||||
|         msg.attach(part) |         msg.attach(part) | ||||||
|  |     click.echo("Sending to: %s" % msg["to"]) | ||||||
|  |  | ||||||
|     conn = smtplib.SMTP("localhost") |     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()) | ||||||
|   | |||||||
| @@ -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 }} | {{ issuer }} has provided {{ user }} a token for retrieving | ||||||
| was stored. You may log in with a certificate authority administration account to sign it. | 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). | ||||||
|  |  | ||||||
|   | |||||||
| @@ -141,14 +141,6 @@ name = Certificate management | |||||||
| address = | address = | ||||||
| ;address = certificates@example.com | ;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] | [tagging] | ||||||
| owner/string = Owner | owner/string = Owner | ||||||
| location/string = Location | location/string = Location | ||||||
| @@ -160,3 +152,18 @@ other/ = Other | |||||||
| # Services template is rendered on certidude server with relevant variables and | # Services template is rendered on certidude server with relevant variables and | ||||||
| # placed to /etc/certidude/services.conf on the client | # placed to /etc/certidude/services.conf on the client | ||||||
| services template = {{ template_path }}/bootstrap.conf | 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 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user