def get_metadata(user_id: Optional[str] = None) -> Mapping[str, Any]: """Returns metadata information, either by running the GET_METADATA_AUTO_CONF_URLS hook function if available, or by checking and returning a local file path or the METADATA_AUTO_CONF_URL. URLs are always validated and invalid URLs will be either filtered or raise a SAMLAuthError exception. Args: user_id (str, optional): If passed, it will be further processed by the GET_METADATA_AUTO_CONF_URLS trigger, which will return the metadata URL corresponding to the given user identifier, either email or username. Defaults to None. Raises: SAMLAuthError: No metadata URL associated with the given user identifier. SAMLAuthError: Invalid metadata URL. Returns: Mapping[str, Any]: Returns a SAML metadata object as dictionary """ get_metadata_trigger = dictor(settings.SAML2_AUTH, "TRIGGER.GET_METADATA_AUTO_CONF_URLS") if get_metadata_trigger: metadata_urls = run_hook(get_metadata_trigger, user_id) if metadata_urls: # Filter invalid metadata URLs filtered_metadata_urls = list( filter(lambda md: validate_metadata_url(md["url"]), metadata_urls)) return {"remote": filtered_metadata_urls} else: raise SAMLAuthError( "No metadata URL associated with the given user identifier.", extra={ "exc_type": ValueError, "error_code": NO_METADATA_URL_ASSOCIATED, "reason": "There was an error processing your request.", "status_code": 500 }) metadata_local_file_path = settings.SAML2_AUTH.get( "METADATA_LOCAL_FILE_PATH") if metadata_local_file_path: return {"local": [metadata_local_file_path]} else: single_metadata_url = settings.SAML2_AUTH.get("METADATA_AUTO_CONF_URL") if validate_metadata_url(single_metadata_url): return {"remote": [{"url": single_metadata_url}]} else: raise SAMLAuthError( "Invalid metadata URL.", extra={ "exc_type": ValueError, "error_code": INVALID_METADATA_URL, "reason": "There was an error processing your request.", "status_code": 500 })
def get_reverse(objects: Union[Any, Iterable[Any]]) -> Optional[str]: """Given one or a list of views/urls(s), returns the corresponding URL to that view. Args: objects (Union[Any, Iterable[Any]]): One or many views/urls representing a resource Raises: SAMLAuthError: We got a URL reverse issue: [...] Returns: Optional[str]: The URL to the resource or None. """ if not isinstance(objects, (list, tuple)): objects = [objects] for obj in objects: try: return reverse(obj) except NoReverseMatch: pass raise SAMLAuthError(f"We got a URL reverse issue: {str(objects)}", extra={ "exc_type": NoReverseMatch, "error_code": NO_REVERSE_MATCH, "reason": "There was an error processing your request.", "status_code": 500 })
def decode_jwt_token(jwt_token: str) -> Optional[str]: """Decode a JWT token Args: jwt_token (str): The token to decode Raises: SAMLAuthError: Cannot decode JWT token. Returns: Optional[str]: A user_id as str or None. """ jwt_secret = settings.SAML2_AUTH.get("JWT_SECRET") jwt_algorithm = settings.SAML2_AUTH.get("JWT_ALGORITHM") try: data = jwt.decode(jwt_token, jwt_secret, algorithms=jwt_algorithm) user_model = get_user_model() return data[user_model.USERNAME_FIELD] except PyJWTError as exc: raise SAMLAuthError("Cannot decode JWT token.", extra={ "exc": exc, "exc_type": type(exc), "error_code": CANNOT_DECODE_JWT_TOKEN, "reason": "Cannot decode JWT token.", "status_code": 500 })
def goodbye(_: HttpRequest) -> None: """Simple view function for testing exception_handler Args: _ (HttpRequest): Incoming HTTP request (not used) Raises: SAMLAuthError: Goodbye, world! """ raise SAMLAuthError("Goodbye, world!", extra={ "exc": RuntimeError("World not found!"), "exc_type": RuntimeError, "error_code": 0, "reason": "Internal world error!", "status_code": 500 })
def sp_initiated_login(request: HttpRequest) -> HttpResponseRedirect: # User must be created first by the IdP-initiated SSO (acs) if request.method == "GET": if request.GET.get("token"): user_id = decode_jwt_token(request.GET.get("token")) saml_client = get_saml_client(get_assertion_url(request), acs, user_id) jwt_token = create_jwt_token(user_id) _, info = saml_client.prepare_for_authenticate( sign=False, relay_state=jwt_token) redirect_url = dict(info["headers"]).get("Location", "") if not redirect_url: return HttpResponseRedirect( get_reverse([denied, "denied", "django_saml2_auth:denied"])) return HttpResponseRedirect(redirect_url) else: raise SAMLAuthError("Request method is not supported.", extra={ "exc_type": Exception, "error_code": INVALID_REQUEST_METHOD, "reason": "Request method is not supported.", "status_code": 404 })
def create_jwt_token(user_id: str) -> Optional[str]: """Create a new JWT token Args: user_id (str): User's username or email based on User.USERNAME_FIELD Raises: SAMLAuthError: Cannot create JWT token. Specify secret and algorithm. Returns: Optional[str]: JWT token """ user_model = get_user_model() jwt_secret = settings.SAML2_AUTH.get("JWT_SECRET") jwt_algorithm = settings.SAML2_AUTH.get("JWT_ALGORITHM") jwt_expiration = settings.SAML2_AUTH.get("JWT_EXP", 60) # default: 1 minute payload = { user_model.USERNAME_FIELD: user_id, "exp": (datetime.utcnow() + timedelta(seconds=jwt_expiration)).timestamp() } if not jwt_secret or not jwt_algorithm: raise SAMLAuthError( "Cannot create JWT token. Specify secret and algorithm.", extra={ "exc_type": Exception, "error_code": NO_JWT_SECRET_OR_ALGORITHM, "reason": "Cannot create JWT token for login.", "status_code": 500 }) jwt_token = jwt.encode(payload, jwt_secret, algorithm=jwt_algorithm) return jwt_token
def run_hook(function_path: str, *args: Optional[Tuple[Any]], **kwargs: Optional[Mapping[str, Any]]) -> Optional[Any]: """Runs a hook function with given args and kwargs. For example, given "models.User.create_new_user", the "create_new_user" function is imported from the "models.User" module and run with args and kwargs. Args: function_path (str): A path to a hook function, e.g. models.User.create_new_user (static method) Raises: SAMLAuthError: function_path isn't specified SAMLAuthError: There's nothing to import. Check your hook's import path! SAMLAuthError: Import error SAMLAuthError: Re-raise any exception caused by the called function Returns: Optional[Any]: Any result returned from running the hook function. None is returned in case of any exceptions, errors in arguments and related issues. """ if not function_path: raise SAMLAuthError("function_path isn't specified", extra={ "exc_type": ValueError, "error_code": EMPTY_FUNCTION_PATH, "reason": "There was an error processing your request.", "status_code": 500 }) path = function_path.split(".") if len(path) < 2: # Nothing to import raise SAMLAuthError( "There's nothing to import. Check your hook's import path!", extra={ "exc_type": ValueError, "error_code": PATH_ERROR, "reason": "There was an error processing your request.", "status_code": 500 }) module_path = ".".join(path[:-1]) result = None try: cls = import_string(module_path) result = getattr(cls, path[-1])(*args, **kwargs) except SAMLAuthError as exc: # Re-raise the exception raise exc except (ImportError, AttributeError) as exc: raise SAMLAuthError(str(exc), extra={ "exc": exc, "exc_type": type(exc), "error_code": IMPORT_ERROR, "reason": "There was an error processing your request.", "status_code": 500 }) except Exception as exc: raise SAMLAuthError(str(exc), extra={ "exc": exc, "exc_type": type(exc), "error_code": GENERAL_EXCEPTION, "reason": "There was an error processing your request.", "status_code": 500 }) return result
def acs(request: HttpRequest): """Assertion Consumer Service is SAML terminology for the location at a ServiceProvider that accepts <samlp:Response> messages (or SAML artifacts) for the purpose of establishing a session based on an assertion. Assertion is a signed authentication request from identity provider (IdP) to acs endpoint. Args: request (HttpRequest): Incoming request from identity provider (IdP) for authentication Exceptions: SAMLAuthError: The target user is inactive. Returns: HttpResponseRedirect: Redirect to various endpoints: denied, welcome or next_url (e.g. the front-end app) Notes: https://wiki.shibboleth.net/confluence/display/CONCEPT/AssertionConsumerService """ authn_response = decode_saml_response(request, acs) user = extract_user_identity(authn_response.get_identity()) next_url = request.session.get("login_next_url") or get_default_next_url() # If RelayState params is passed, it is a JWT token that identifies the user trying to login # via sp_initiated_login endpoint relay_state = request.POST.get("RelayState") if relay_state: redirected_user_id = decode_jwt_token(relay_state) # This prevents users from entering an email on the SP, but use a different email on IdP if get_user_id(user) != redirected_user_id: raise SAMLAuthError("The user identifier doesn't match.", extra={ "exc_type": ValueError, "error_code": USER_MISMATCH, "reason": "User identifier mismatch.", "status_code": 403 }) is_new_user, target_user = get_or_create_user(user) before_login_trigger = dictor(settings.SAML2_AUTH, "TRIGGER.BEFORE_LOGIN") if before_login_trigger: run_hook(before_login_trigger, user) request.session.flush() use_jwt = settings.SAML2_AUTH.get("USE_JWT", False) if use_jwt and target_user.is_active: # Create a new JWT token for IdP-initiated login (acs) jwt_token = create_jwt_token(target_user.email) # Use JWT auth to send token to frontend query = f"?token={jwt_token}" frontend_url = settings.SAML2_AUTH.get("FRONTEND_URL", next_url) return HttpResponseRedirect(frontend_url + query) if target_user.is_active: model_backend = "django.contrib.auth.backends.ModelBackend" login(request, target_user, model_backend) after_login_trigger = dictor(settings.SAML2_AUTH, "TRIGGER.AFTER_LOGIN") if after_login_trigger: run_hook(after_login_trigger, request.session, user) else: raise SAMLAuthError("The target user is inactive.", extra={ "exc_type": Exception, "error_code": INACTIVE_USER, "reason": "User is inactive.", "status_code": 500 }) if is_new_user: try: return render(request, "django_saml2_auth/welcome.html", {"user": request.user}) except TemplateDoesNotExist: return HttpResponseRedirect(next_url) else: return HttpResponseRedirect(next_url)
def extract_user_identity( user_identity: Dict[str, Any]) -> Dict[str, Optional[Any]]: """Extract user information from SAML user identity object Args: user_identity (Dict[str, Any]): SAML user identity object (dict) Raises: SAMLAuthError: No token specified. SAMLAuthError: No username or email provided. Returns: Dict[str, Optional[Any]]: Cleaned user information plus user_identity for backwards compatibility """ email_field = dictor(settings, "ATTRIBUTES_MAP.email", default="user.email") username_field = dictor(settings, "ATTRIBUTES_MAP.username", default="user.username") firstname_field = dictor(settings, "ATTRIBUTES_MAP.first_name", default="user.first_name") lastname_field = dictor(settings, "ATTRIBUTES_MAP.last_name", default="user.last_name") token_field = dictor(settings, "ATTRIBUTES_MAP.token", default="token") user = {} user["email"] = dictor(user_identity, f"{email_field}/0", pathsep="/") # Path includes "." user["username"] = dictor(user_identity, f"{username_field}/0", pathsep="/") user["first_name"] = dictor(user_identity, f"{firstname_field}/0", pathsep="/") user["last_name"] = dictor(user_identity, f"{lastname_field}/0", pathsep="/") user["token"] = dictor(user_identity, f"{token_field}.0") if user["email"]: user["email"] = user["email"].lower() if user["username"]: user["username"] = user["username"].lower() # For backwards compatibility user["user_identity"] = user_identity if not user["email"] and not user["username"]: raise SAMLAuthError( "No username or email provided.", extra={ "exc_type": ValueError, "error_code": NO_USERNAME_OR_EMAIL_SPECIFIED, "reason": "Username or email must be configured on the SAML app before logging in.", "status_code": 422 }) if not user["token"]: raise SAMLAuthError( "No token specified.", extra={ "exc_type": ValueError, "error_code": NO_TOKEN_SPECIFIED, "reason": "Token must be configured on the SAML app before logging in.", "status_code": 422 }) return user
def decode_saml_response( request: HttpRequest, acs: Callable[..., HttpResponse] ) -> Union[HttpResponseRedirect, Optional[AuthnResponse]]: """Given a request, the authentication response inside the SAML response body is parsed, decoded and returned. If there are any issues parsing the request, the identity or the issuer, an exception is raised. Args: request (HttpRequest): Django request object from identity provider (IdP) acs (Callable[..., HttpResponse]): The acs endpoint Raises: SAMLAuthError: There was no response from SAML client. SAMLAuthError: There was no response from SAML identity provider. SAMLAuthError: No name_id in SAML response. SAMLAuthError: No issuer/entity_id in SAML response. SAMLAuthError: No user identity in SAML response. Returns: Union[HttpResponseRedirect, Optional[AuthnResponse]]: Returns an AuthnResponse object for extracting user identity from. """ saml_client = get_saml_client(get_assertion_url(request), acs) if not saml_client: raise SAMLAuthError("There was an error creating the SAML client.", extra={ "exc_type": ValueError, "error_code": NO_SAML_CLIENT, "reason": "There was an error processing your request.", "status_code": 500 }) response = request.POST.get("SAMLResponse") or None if not response: raise SAMLAuthError("There was no response from SAML client.", extra={ "exc_type": ValueError, "error_code": NO_SAML_RESPONSE_FROM_CLIENT, "reason": "There was an error processing your request.", "status_code": 500 }) authn_response = saml_client.parse_authn_request_response( response, entity.BINDING_HTTP_POST) if not authn_response: raise SAMLAuthError( "There was no response from SAML identity provider.", extra={ "exc_type": ValueError, "error_code": NO_SAML_RESPONSE_FROM_IDP, "reason": "There was an error processing your request.", "status_code": 500 }) if not authn_response.name_id: raise SAMLAuthError("No name_id in SAML response.", extra={ "exc_type": ValueError, "error_code": NO_NAME_ID_IN_SAML_RESPONSE, "reason": "There was an error processing your request.", "status_code": 500 }) if not authn_response.issuer(): raise SAMLAuthError("No issuer/entity_id in SAML response.", extra={ "exc_type": ValueError, "error_code": NO_ISSUER_IN_SAML_RESPONSE, "reason": "There was an error processing your request.", "status_code": 500 }) if not authn_response.get_identity(): raise SAMLAuthError("No user identity in SAML response.", extra={ "exc_type": ValueError, "error_code": NO_USER_IDENTITY_IN_SAML_RESPONSE, "reason": "There was an error processing your request.", "status_code": 500 }) return authn_response
def get_saml_client(domain: str, acs: Callable[..., HttpResponse], user_id: str = None) -> Optional[Saml2Client]: """Create a new Saml2Config object with the given config and return an initialized Saml2Client using the config object. The settings are read from django settings key: SAML2_AUTH. Args: domain (str): Domain name to get SAML config for acs (Callable[..., HttpResponse]): The acs endpoint Raises: SAMLAuthError: Re-raise any exception raised by Saml2Config or Saml2Client Returns: Optional[Saml2Client]: A Saml2Client or None """ acs_url = domain + get_reverse([acs, "acs", "django_saml2_auth:acs"]) metadata = get_metadata(user_id) if (("local" in metadata and not metadata["local"]) or ("remote" in metadata and not metadata["remote"])): raise SAMLAuthError("Metadata URL/file is missing.", extra={ "exc_type": NoReverseMatch, "error_code": NO_METADATA_URL_OR_FILE, "reason": "There was an error processing your request.", "status_code": 500 }) saml_settings = { "metadata": metadata, "allow_unknown_attributes": True, "debug": settings.SAML2_AUTH.get("DEBUG", False), "service": { "sp": { "endpoints": { "assertion_consumer_service": [(acs_url, BINDING_HTTP_REDIRECT), (acs_url, BINDING_HTTP_POST)], }, "allow_unsolicited": True, "authn_requests_signed": True, "logout_requests_signed": True, "want_assertions_signed": dictor(settings, "SAML2_AUTH.WANT_ASSERTIONS_SIGNED", default=True), "want_response_signed": dictor(settings, "SAML2_AUTH.WANT_RESPONSE_SIGNED", default=True), }, }, } entity_id = settings.SAML2_AUTH.get("ENTITY_ID") if entity_id: saml_settings["entityid"] = entity_id name_id_format = settings.SAML2_AUTH.get("NAME_ID_FORMAT") if name_id_format: saml_settings["service"]["sp"]["name_id_format"] = name_id_format try: sp_config = Saml2Config() sp_config.load(saml_settings) saml_client = Saml2Client(config=sp_config) return saml_client except Exception as exc: raise SAMLAuthError(str(exc), extra={ "exc": exc, "exc_type": type(exc), "error_code": ERROR_CREATING_SAML_CONFIG_OR_CLIENT, "reason": "There was an error processing your request.", "status_code": 500 })
def get_or_create_user(user: Dict[str, Any]) -> Tuple[bool, Type[Model]]: """Get or create a new user and optionally add it to one or more group(s) Args: user (Dict[str, Any]): User information Raises: SAMLAuthError: Cannot create user. Returns: Tuple[bool, Type[Model]]: A tuple containing user creation status and user object """ user_model = get_user_model() created = False try: target_user = get_user(user) except user_model.DoesNotExist: should_create_new_user = settings.SAML2_AUTH.get("CREATE_USER", True) if should_create_new_user: target_user = create_new_user(get_user_id(user), user["first_name"], user["last_name"]) create_user_trigger = dictor(settings.SAML2_AUTH, "TRIGGER.CREATE_USER") if create_user_trigger: run_hook(create_user_trigger, user) target_user.refresh_from_db() created = True else: raise SAMLAuthError( "Cannot create user.", extra={ "exc_type": Exception, "error_code": SHOULD_NOT_CREATE_USER, "reason": "Due to current config, a new user should not be created.", "status_code": 500 }) # Optionally update this user's group assignments by updating group memberships from SAML groups # to Django equivalents group_attribute = dictor(settings.SAML2_AUTH, "ATTRIBUTES_MAP.groups") group_map = settings.SAML2_AUTH.get("GROUPS_MAP") if group_attribute and group_attribute in user["user_identity"]: groups = [] for group_name in user["user_identity"][group_attribute]: # Group names can optionally be mapped to different names in Django if group_map and group_name in group_map: group_name_django = group_map[group_name] else: group_name_django = group_name try: groups.append(Group.objects.get(name=group_name_django)) except Group.DoesNotExist: pass if parse_version(get_version()) >= parse_version("2.0"): target_user.groups.set(groups) else: target_user.groups = groups return (created, target_user)
def create_new_user(email: str, firstname: str, lastname: str) -> Type[Model]: """Create a new user with the given information Args: email (str): Email firstname (str): First name lastname (str): Last name Raises: SAMLAuthError: There was an error creating the new user. SAMLAuthError: There was an error joining the user to the group. Returns: Type[Model]: Returns a new user object, usually a subclass of the the User model """ user_model = get_user_model() is_active = dictor(settings.SAML2_AUTH, "NEW_USER_PROFILE.ACTIVE_STATUS", default=True) is_staff = dictor(settings.SAML2_AUTH, "NEW_USER_PROFILE.STAFF_STATUS", default=False) is_superuser = dictor(settings.SAML2_AUTH, "NEW_USER_PROFILE.SUPERUSER_STATUS", default=False) user_groups = dictor(settings.SAML2_AUTH, "NEW_USER_PROFILE.USER_GROUPS", default=[]) try: user = user_model.objects.create_user(email, first_name=firstname, last_name=lastname) user.is_active = is_active user.is_staff = is_staff user.is_superuser = is_superuser user.save() except Exception as exc: raise SAMLAuthError("There was an error creating the new user.", extra={ "exc": exc, "exc_type": type(exc), "error_code": CREATE_USER_ERROR, "reason": "There was an error processing your request.", "status_code": 500 }) try: groups = [Group.objects.get(name=group) for group in user_groups] if groups: if parse_version(get_version()) <= parse_version("1.8"): user.groups = groups else: user.groups.set(groups) except Exception as exc: raise SAMLAuthError( "There was an error joining the user to the group.", extra={ "exc": exc, "exc_type": type(exc), "error_code": GROUP_JOIN_ERROR, "reason": "There was an error processing your request.", "status_code": 500 }) user.save() user.refresh_from_db() return user