Example #1
0
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
                })
Example #2
0
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
                        })
Example #3
0
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
                            })
Example #4
0
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
                        })
Example #5
0
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
                            })
Example #6
0
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
Example #7
0
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
Example #8
0
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)
Example #9
0
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
Example #10
0
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
Example #11
0
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
                            })
Example #12
0
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)
Example #13
0
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