Exemplo n.º 1
0
def test_run_hook_nothing_to_import():
    """Test run_hook function by passing function name only (no path) and checking if it raises."""
    with pytest.raises(SAMLAuthError) as exc_info:
        run_hook("divide")

    assert str(exc_info.value
               ) == "There's nothing to import. Check your hook's import path!"
Exemplo n.º 2
0
def test_run_hook_no_function_path():
    """Test run_hook function by passing invalid function path and checking if it raises."""
    with pytest.raises(SAMLAuthError) as exc_info:
        run_hook("")
        run_hook(None)

    assert str(exc_info.value) == "function_path isn't specified"
Exemplo n.º 3
0
def test_run_hook_division_by_zero():
    """Test function imported by run_hook to verify if run_hook correctly captures the exception."""
    with pytest.raises(SAMLAuthError) as exc_info:
        run_hook("django_saml2_auth.tests.test_utils.divide", 2, b=0)

    assert str(exc_info.value) == "division by zero"
    # Actually a ZeroDivisionError wrapped in SAMLAuthError
    assert isinstance(exc_info.value.extra["exc"], ZeroDivisionError)
    assert exc_info.value.extra["exc_type"] == ZeroDivisionError
Exemplo n.º 4
0
def test_run_hook_import_error():
    """Test run_hook function by passing correct path, but nonexistent function and
    checking if it raises."""
    with pytest.raises(SAMLAuthError) as exc_info:
        run_hook("django_saml2_auth.tests.test_utils.nonexistent_divide",
                 2,
                 b=2)

    assert str(exc_info.value) == (
        "module 'django_saml2_auth.tests.test_utils' has no attribute 'nonexistent_divide'"
    )
    assert isinstance(exc_info.value.extra["exc"], AttributeError)
    assert exc_info.value.extra["exc_type"] == AttributeError
Exemplo n.º 5
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
                })
Exemplo n.º 6
0
def test_run_hook_success():
    """Test run_hook function against divide function imported from current module."""
    result = run_hook("django_saml2_auth.tests.test_utils.divide", 2, b=2)
    assert result == 1
Exemplo n.º 7
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)
Exemplo n.º 8
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)