]> git.ipfire.org Git - ipfire.org.git/commitdiff
people: Implement SSO for Discourse
authorMichael Tremer <michael.tremer@ipfire.org>
Tue, 22 Oct 2019 15:01:08 +0000 (16:01 +0100)
committerMichael Tremer <michael.tremer@ipfire.org>
Tue, 22 Oct 2019 15:01:08 +0000 (16:01 +0100)
This patch only adds SSO for users who are already logged in
and does not have any UI for logging in, yet.

Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
src/backend/accounts.py
src/web/__init__.py
src/web/people.py

index d79c21126a7dfc7051c10c6bd8eb331fc362a86e..86a9bd24d8a02f89f1dddc9e53ac35ee760aec8e 100644 (file)
@@ -1,7 +1,9 @@
 #!/usr/bin/python
 # encoding: utf-8
 
+import base64
 import datetime
+import hmac
 import json
 import ldap
 import ldap.modlist
@@ -288,6 +290,42 @@ class Accounts(Object):
                # Cleanup expired account activations
                self.db.execute("DELETE FROM account_activations WHERE expires_at <= NOW()")
 
+       # Discourse
+
+       def decode_discourse_payload(self, payload, signature):
+               # Check signature
+               calculated_signature = self.sign_discourse_payload(payload)
+
+               if not hmac.compare_digest(signature, calculated_signature):
+                       raise ValueError("Invalid signature: %s" % signature)
+
+               # Decode the query string
+               qs = base64.b64decode(payload).decode()
+
+               # Parse the query string
+               data = {}
+               for key, val in urllib.parse.parse_qsl(qs):
+                       data[key] = val
+
+               return data
+
+       def encode_discourse_payload(self, **args):
+               # Encode the arguments into an URL-formatted string
+               qs = urllib.parse.urlencode(args).encode()
+
+               # Encode into base64
+               return base64.b64encode(qs).decode()
+
+       def sign_discourse_payload(self, payload, secret=None):
+               if secret is None:
+                       secret = self.settings.get("discourse_sso_secret")
+
+               # Calculate a HMAC using SHA256
+               h = hmac.new(secret.encode(),
+                       msg=payload.encode(), digestmod="sha256")
+
+               return h.hexdigest()
+
 
 class Account(Object):
        def __init__(self, backend, dn, attrs=None):
@@ -474,7 +512,7 @@ class Account(Object):
                ))
 
        def is_admin(self):
-               return "wheel" in self.groups
+               return "sudo" in self.groups
 
        def is_staff(self):
                return "staff" in self.groups
@@ -823,12 +861,7 @@ class Account(Object):
                return ret
 
        def avatar_url(self, size=None):
-               if self.backend.debug:
-                       hostname = "http://people.dev.ipfire.org"
-               else:
-                       hostname = "https://people.ipfire.org"
-
-               url = "%s/users/%s.jpg" % (hostname, self.uid)
+               url = "https://people.ipfire.org/users/%s.jpg" % self.uid
 
                if size:
                        url += "?size=%s" % size
index 53fc896b981d84fb5c8f8ae455574ea2a47bbb98..7fff703646afedbb81bfdded5cc697f22a7de2ce 100644 (file)
@@ -284,6 +284,9 @@ class Application(tornado.web.Application):
                        (r"/users/(\w+)/ssh-keys/(SHA256\:.*)", people.SSHKeysDownloadHandler),
                        (r"/users/(\w+)/ssh-keys/upload", people.SSHKeysUploadHandler),
                        (r"/users/(\w+)/sip", people.SIPHandler),
+
+                       # Single-Sign-On for Discourse
+                       (r"/sso/discourse", people.SSODiscourse),
                ]  + authentication_handlers)
 
                # wiki.ipfire.org
index d923ed782fd1b60fb2b72b4a0d9584c31b31f64a..69dcf4e84c23cacb0b84bf7ce41be4e387e2ac65 100644 (file)
@@ -6,6 +6,7 @@ import logging
 import imghdr
 import sshpubkeys
 import tornado.web
+import urllib.parse
 
 from .. import countries
 
@@ -366,6 +367,74 @@ class UserPasswdHandler(auth.CacheMixin, base.BaseHandler):
                self.redirect("/users/%s" % account.uid)
 
 
+class SSODiscourse(auth.CacheMixin, base.BaseHandler):
+       def _get_discourse_params(self):
+               # Fetch Discourse's parameters
+               sso = self.get_argument("sso")
+               sig = self.get_argument("sig")
+
+               # Decode payload
+               try:
+                       return self.accounts.decode_discourse_payload(sso, sig)
+
+               # Raise bad request if the signature is invalid
+               except ValueError:
+                       raise tornado.web.HTTPError(400)
+
+       def _redirect_user_to_discourse(self, account, nonce, return_sso_url):
+               """
+                       Redirects the user back to Discourse passing some
+                       attributes of the user account to Discourse
+               """
+               args = {
+                       "nonce" : nonce,
+                       "external_id" : account.uid,
+
+                       # Pass email address
+                       "email" : account.email,
+                       "require_activation" : "false",
+
+                       # More details about the user
+                       "username" : account.uid,
+                       "name" : "%s" % account,
+
+                       # Avatar
+                       "avatar_url" : account.avatar_url(),
+                       "avatar_force_update" : "true",
+
+                       # Send a welcome message
+                       "suppress_welcome_message" : "false",
+
+                       # Group memberships
+                       "admin" : "true" if account.is_admin() else "false",
+                       "moderator" : "true" if account.is_staff() else "false",
+               }
+
+               # Format payload and sign it
+               payload = self.accounts.encode_discourse_payload(**args)
+               signature = self.accounts.sign_discourse_payload(payload)
+
+               qs = urllib.parse.urlencode({
+                       "sso" : payload,
+                       "sig" : signature,
+               })
+
+               # Redirect user
+               self.redirect("%s?%s" % (return_sso_url, qs))
+
+       @base.ratelimit(minutes=24*60, requests=100)
+       def get(self):
+               params = self._get_discourse_params()
+
+               # Redirect back if user is already logged in
+               if self.current_user:
+                       return self._redirect_user_to_discourse(self.current_user, **params)
+
+               # Otherwise the user needs to authenticate
+               # XXX
+               raise tornado.web.HTTPError(401)
+
+
 class NewAccountsModule(ui_modules.UIModule):
        def render(self, days=14):
                t = datetime.datetime.utcnow() - datetime.timedelta(days=days)