import hmac import hashlib import base64 import json import urllib.parse from Crypto.Cipher import AES import logging import traceback from django.conf import settings from .exceptions import AuthError logger = logging.getLogger(__name__) class SSOAuthCore: """ Pingcap single sign on for Discourse Ref: https://meta.discourse.org/t/official-single-sign-on-for-discourse-sso/13045 Attention: nonce will expire after 10 minute of creation, this may break user experience """ def validate_authentication_request(self, sso, sig, secret): """ :param sso: Payload is Base64URL encoded, decode it to get `nonce` :param sig: Used to validate sso, algorithm: HMAC-SHA256 :param secret: SSO secret key """ if not (sso and sig): raise AuthError('sso and sig is required') sig2 = hmac.new( secret.encode('utf-8'), sso.encode('utf-8'), digestmod=hashlib.sha256).hexdigest() if sig != sig2: raise AuthError('invalid sig') try: payload = dict(urllib.parse.parse_qsl(base64.b64decode(sso))) nonce = payload[b'nonce'] except Exception as e: logger.debug(e) raise AuthError('failed to pasrse sso') return nonce.decode('utf-8') def create_response_payload(self, nonce, email, require_activation, external_id, username, name, secret): """ :param nonce: should be copied from the request payload :param email: must be a verified email address. If the email address has not be verified, set `require_activation` to true :param external_id: user's openid, unique to each user :param username: will become the username on discourse if the user is new :param name: will become the full name on discourse if the user is new """ # TODO: validate input payload = urllib.parse.urlencode({ 'nonce': nonce, 'email': email, 'require_activation': require_activation, 'external_id': external_id, 'username': username, 'name': name }) payload = base64.b64encode(payload.encode('utf-8')) sig = hmac.new( secret.encode('utf-8'), payload, digestmod=hashlib.sha256).hexdigest() return urllib.parse.urlencode({'sso': payload, 'sig': sig}) # Decrypt Discourse auth sercure session cookie # Refer: https://gist.github.com/wildjcrt/6359713fa770d277927051fdeb30ebbf?permalink_comment_id=4157641 def decrypt_secure_session_cookie(cookie, secret_key_base=settings.DISCOURSE_SECRET_TOKEN): # cookie: value of '_t' cookie # secret_key_base: Rails.application.config.secret_key_base assert cookie and secret_key_base, 'cookie and secret_key_base is required' try: cookie = urllib.parse.unquote(cookie) data, iv, auth_tag = cookie.split('--') data = data.encode('utf8') iv = iv.encode('utf8') auth_tag = auth_tag.encode('utf8') data = base64.b64decode(data + b'=' * (-len(data) % 4)) iv = base64.b64decode(iv + b'=' * (-len(iv) % 4)) auth_tag = base64.b64decode(auth_tag + b'=' * (-len(auth_tag) % 4)) secret = hashlib.pbkdf2_hmac('sha1', secret_key_base.encode('utf8'), b'authenticated encrypted cookie', 1000, dklen=32) cipher = AES.new(secret, AES.MODE_GCM, iv) cookie_payload = cipher.decrypt_and_verify(data, auth_tag).decode('utf8') cookie_payload = json.loads(cookie_payload) decoded_stored_value = base64.b64decode(cookie_payload["_rails"]["message"]) auth_cookie = json.loads(decoded_stored_value) token = auth_cookie['token'] user_id = auth_cookie['user_id'] issued_at = auth_cookie['issued_at'] return token, user_id, issued_at except Exception: logger.error(traceback.format_exc()) return None, None, None def hash_user_auth_token(auth_token): return base64.b64encode( hashlib.sha1( (auth_token + settings.DISCOURSE_SECRET_TOKEN).encode('utf8') ).digest() ).decode('utf8')