Skip to content

Commit

Permalink
✨(api) allow multiple authentication backends simultaneously
Browse files Browse the repository at this point in the history
Previously, Ralph allowed Basic or OIDC authentication, but not simultaneously.
This PR allows to ralph to handle both at once, answer a use case where machine
users connect through Basic auth, while human users use OIDC (for example).
  • Loading branch information
Leobouloc committed Nov 15, 2023
1 parent 08443f6 commit f44d39e
Show file tree
Hide file tree
Showing 19 changed files with 346 additions and 187 deletions.
2 changes: 1 addition & 1 deletion .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ RALPH_BACKENDS__HTTP__LRS__STATEMENTS_ENDPOINT=/xAPI/statements

# LRS API

RALPH_RUNSERVER_AUTH_BACKEND=basic
RALPH_RUNSERVER_AUTH_BACKENDS=basic
RALPH_RUNSERVER_AUTH_OIDC_AUDIENCE=http://localhost:8100
RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI=http://learning-analytics-playground_keycloak_1:8080/auth/realms/fun-mooc
RALPH_RUNSERVER_BACKEND=es
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ have an authority field matching that of the user
- Backends: Replace reference to a JSON column in ClickHouse with
function calls on the String column [BC]
- API: enhance 'limit' query parameter's validation
- API: Variable `RUNSERVER_AUTH_BACKEND` becomes `RUNSERVER_AUTH_BACKENDS`, and
multiple authentication methods are supported simultaneously

### Fixed

Expand Down
5 changes: 3 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,10 @@ $ curl --user [email protected]:PASSWORD http://localhost:8100/whoami

Ralph LRS API server supports OpenID Connect (OIDC) on top of OAuth 2.0 for authentication and authorization.

To enable OIDC auth, you should set the `RALPH_RUNSERVER_AUTH_BACKEND` environment variable as follows:

To enable OIDC auth, you should modify the `RALPH_RUNSERVER_AUTH_BACKENDS` environment variable by adding (or replacing by) `oidc`:
```bash
RALPH_RUNSERVER_AUTH_BACKEND=oidc
RALPH_RUNSERVER_AUTH_BACKENDS=basic,oidc
```
and you should define the `RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI` environment variable with your identity provider's Issuer Identifier URI as follows:
```bash
Expand Down
6 changes: 3 additions & 3 deletions docs/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Elasticsearch backend parameters required to connect to a cluster are:

- `hosts`: a list of cluster hosts to connect to (_e.g._ `["http://elasticsearch-node:9200"]`)
- `index`: the elasticsearch index where to get/put documents
- `client_options`: a comma separated key=value list of Elasticsearch client options
- `client_options`: a comma-separated key=value list of Elasticsearch client options

The Elasticsearch client options supported in Ralph are:
- `ca_certs`: the path to the CA certificate file.
Expand All @@ -177,7 +177,7 @@ MongoDB backend parameters required to connect to a cluster are:
- `connection_uri`: the connection URI to connect to (_e.g._ `["mongodb://mongo:27017/"]`)
- `database`: the database to connect to
- `collection`: the collection to get/put objects to
- `client_options`: a comma separated key=value list of MongoDB client options
- `client_options`: a comma-separated key=value list of MongoDB client options

The MongoDB client options supported in Ralph are:
- `document_class`: default class to use for documents returned from queries
Expand All @@ -196,7 +196,7 @@ ClickHouse parameters required to connect are:
- `port`: the port to the ClickHouse HTTPS interface (_e.g._ `8123`)
- `database`: the name of the database to connect to
- `event_table_name`: the name of the table to write statements to
- `client_options`: a comma separated key=value list of ClickHouse client options
- `client_options`: a comma-separated key=value list of ClickHouse client options

Secondary parameters are needed if not using the default ClickHouse user:

Expand Down
54 changes: 47 additions & 7 deletions src/ralph/api/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,51 @@
"""Main module for Ralph's LRS API authentication."""
from typing import Optional

from ralph.api.auth.basic import get_basic_auth_user
from fastapi import Depends, HTTPException, status
from fastapi.security import SecurityScopes

from ralph.api.auth.basic import AuthenticatedUser, get_basic_auth_user
from ralph.api.auth.oidc import get_oidc_user
from ralph.conf import settings
from ralph.conf import AuthBackend, settings


def get_authenticated_user(
security_scopes: SecurityScopes = SecurityScopes([]),
basic_auth_user: Optional[AuthenticatedUser] = Depends(get_basic_auth_user),
oidc_auth_user: Optional[AuthenticatedUser] = Depends(get_oidc_user),
) -> AuthenticatedUser:
"""Authenticate user with any allowed method, using credentials in the header."""
if AuthBackend.BASIC not in settings.RUNSERVER_AUTH_BACKENDS:
basic_auth_user = None
if AuthBackend.OIDC not in settings.RUNSERVER_AUTH_BACKENDS:
oidc_auth_user = None

if basic_auth_user:
user = basic_auth_user
auth_header = "Basic"
elif oidc_auth_user:
user = oidc_auth_user
auth_header = "Bearer"
else:
auth_header = ",".join(
[
{"basic": "Basic", "oidc": "Bearer"}[backend.value]
for backend in settings.RUNSERVER_AUTH_BACKENDS
]
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": auth_header},
)

# At startup, select the authentication mode that will be used
if settings.RUNSERVER_AUTH_BACKEND == settings.AuthBackends.OIDC:
get_authenticated_user = get_oidc_user
else:
get_authenticated_user = get_basic_auth_user
# Restrict access by scopes
if settings.LRS_RESTRICT_BY_SCOPES:
for requested_scope in security_scopes.scopes:
if not user.scopes.is_authorized(requested_scope):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f'Access not authorized to scope: "{requested_scope}".',
headers={"WWW-Authenticate": auth_header},
)
return user
24 changes: 4 additions & 20 deletions src/ralph/api/auth/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import bcrypt
from cachetools import TTLCache, cached
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials, SecurityScopes
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel, root_validator
from starlette.authentication import AuthenticationError

Expand Down Expand Up @@ -102,17 +102,15 @@ def get_stored_credentials(auth_file: Path) -> ServerUsersCredentials:
@cached(
TTLCache(maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_CACHE_TTL),
lock=Lock(),
key=lambda credentials, security_scopes: (
key=lambda credentials: (
credentials.username,
credentials.password,
security_scopes.scope_str,
)
if credentials is not None
else None,
)
def get_basic_auth_user(
credentials: Optional[HTTPBasicCredentials] = Depends(security),
security_scopes: SecurityScopes = SecurityScopes([]),
) -> AuthenticatedUser:
"""Check valid auth parameters.
Expand All @@ -121,18 +119,13 @@ def get_basic_auth_user(
Args:
credentials (iterator): auth parameters from the Authorization header
security_scopes: scopes requested for access
Raises:
HTTPException
"""
if not credentials:
logger.error("The basic authentication mode requires a Basic Auth header")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Basic"},
)
logger.debug("No credentials were found for Basic auth")
return None

try:
user = next(
Expand Down Expand Up @@ -185,13 +178,4 @@ def get_basic_auth_user(

user = AuthenticatedUser(scopes=user.scopes, agent=dict(user.agent))

# Restrict access by scopes
if settings.LRS_RESTRICT_BY_SCOPES:
for requested_scope in security_scopes.scopes:
if not user.scopes.is_authorized(requested_scope):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f'Access not authorized to scope: "{requested_scope}".',
headers={"WWW-Authenticate": "Basic"},
)
return user
26 changes: 7 additions & 19 deletions src/ralph/api/auth/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import requests
from fastapi import Depends, HTTPException, status
from fastapi.security import OpenIdConnect, SecurityScopes
from fastapi.security import HTTPBearer, OpenIdConnect
from jose import ExpiredSignatureError, JWTError, jwt
from jose.exceptions import JWTClaimsError
from pydantic import AnyUrl, BaseModel, Extra
Expand Down Expand Up @@ -94,8 +94,7 @@ def get_public_keys(jwks_uri: AnyUrl) -> Dict:


def get_oidc_user(
auth_header: Annotated[Optional[str], Depends(oauth2_scheme)],
security_scopes: SecurityScopes = SecurityScopes([]),
auth_header: Annotated[Optional[HTTPBearer], Depends(oauth2_scheme)],
) -> AuthenticatedUser:
"""Decode and validate OpenId Connect ID token against issuer in config.
Expand All @@ -110,13 +109,12 @@ def get_oidc_user(
Raises:
HTTPException
"""
if auth_header is None or "Bearer" not in auth_header:
logger.error("The OpenID Connect authentication mode requires a Bearer token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
if auth_header is None or "bearer" not in auth_header.lower():
logger.debug(
"Not using OIDC auth. The OpenID Connect authentication mode requires a "
"Bearer token"
)
return None

id_token = auth_header.split(" ")[-1]
provider_config = discover_provider(settings.RUNSERVER_AUTH_OIDC_ISSUER_URI)
Expand Down Expand Up @@ -151,14 +149,4 @@ def get_oidc_user(
scopes=UserScopes(id_token.scope.split(" ") if id_token.scope else []),
)

# Restrict access by scopes
if settings.LRS_RESTRICT_BY_SCOPES:
for requested_scope in security_scopes.scopes:
if not user.scopes.is_authorized(requested_scope):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f'Access not authorized to scope: "{requested_scope}".',
headers={"WWW-Authenticate": "Basic"},
)

return user
2 changes: 1 addition & 1 deletion src/ralph/backends/data/es.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class ESDataBackendSettings(BaseDataBackendSettings):
DEFAULT_CHUNK_SIZE (int): The default chunk size for reading batches of
documents.
DEFAULT_INDEX (str): The default index to use for querying Elasticsearch.
HOSTS (str or tuple): The comma separated list of Elasticsearch nodes to
HOSTS (str or tuple): The comma-separated list of Elasticsearch nodes to
connect to.
LOCALE_ENCODING (str): The encoding used for reading/writing documents.
POINT_IN_TIME_KEEP_ALIVE (str): The duration for which Elasticsearch should
Expand Down
8 changes: 4 additions & 4 deletions src/ralph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@


class CommaSeparatedTupleParamType(click.ParamType):
"""Comma separated tuple parameter type."""
"""Comma-separated tuple parameter type."""

name = "value1,value2,value3"

Expand All @@ -81,7 +81,7 @@ def convert(self, value, param, ctx):


class CommaSeparatedKeyValueParamType(click.ParamType):
"""Comma separated key=value parameter type."""
"""Comma-separated key=value parameter type."""

name = "key=value,key=value"

Expand Down Expand Up @@ -123,7 +123,7 @@ def convert(self, value, param, ctx):


class ClientOptionsParamType(CommaSeparatedKeyValueParamType):
"""Comma separated key=value parameter type for client options."""
"""Comma-separated key=value parameter type for client options."""

def __init__(self, client_options_type: Any) -> None:
"""Instantiate ClientOptionsParamType for a client_options_type.
Expand All @@ -145,7 +145,7 @@ def convert(self, value, param, ctx):


class HeadersParametersParamType(CommaSeparatedKeyValueParamType):
"""Comma separated key=value parameter type for headers parameters."""
"""Comma-separated key=value parameter type for headers parameters."""

def __init__(self, headers_parameters_type: Any) -> None:
"""Instantiate HeadersParametersParamType for a headers_parameters_type.
Expand Down
54 changes: 43 additions & 11 deletions src/ralph/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sys
from enum import Enum
from pathlib import Path
from typing import List, Sequence, Union
from typing import List, Sequence, Tuple, Union

from pydantic import AnyHttpUrl, AnyUrl, BaseModel, BaseSettings, Extra, root_validator

Expand Down Expand Up @@ -53,19 +53,19 @@ class Config(BaseSettingsConfig):


class CommaSeparatedTuple(str):
"""Pydantic field type validating comma separated strings or lists/tuples."""
"""Pydantic field type validating comma-separated strings or lists/tuples."""

@classmethod
def __get_validators__(cls): # noqa: D105
def validate(value: Union[str, Sequence[str]]) -> Sequence[str]:
"""Check whether the value is a comma separated string or a list/tuple."""
"""Check whether the value is a comma-separated string or a list/tuple."""
if isinstance(value, (tuple, list)):
return tuple(value)

if isinstance(value, str):
return tuple(value.split(","))

raise TypeError("Invalid comma separated list")
raise TypeError("Invalid comma-separated list")

yield validate

Expand Down Expand Up @@ -133,6 +133,44 @@ class Config: # noqa: D106
timeout: float


class AuthBackend(str, Enum):
"""Model for valid authentication methods."""

BASIC = "basic"
OIDC = "oidc"


class AuthBackends(Tuple[AuthBackend]):
"""Model representing a tuple of authentication backends."""

@classmethod
def __get_validators__(cls):
"""Check whether the value is a comma-separated string or a tuple representing
an AuthBackend.
""" # noqa: D205

def validate(
auth_backends: Union[
str, AuthBackend, Tuple[AuthBackend], List[AuthBackend]
]
) -> Tuple[AuthBackend]:
"""Check whether the value is a comma-separated string or a list/tuple."""
if isinstance(auth_backends, str):
return tuple(
AuthBackend(value.lower()) for value in auth_backends.split(",")
)

if isinstance(auth_backends, AuthBackend):
return (auth_backends,)

if isinstance(auth_backends, (tuple, list)):
return tuple(auth_backends)

raise TypeError("Invalid comma-separated list")

yield validate


class Settings(BaseSettings):
"""Pydantic model for Ralph's global environment & configuration settings."""

Expand All @@ -142,12 +180,6 @@ class Config(BaseSettingsConfig):
env_file = ".env"
env_file_encoding = core_settings.LOCALE_ENCODING

class AuthBackends(Enum):
"""Enum of the authentication backends."""

BASIC = "basic"
OIDC = "oidc"

_CORE: CoreSettings = core_settings
AUTH_FILE: Path = _CORE.APP_DIR / "auth.json"
AUTH_CACHE_MAX_SIZE = 100
Expand Down Expand Up @@ -188,7 +220,7 @@ class AuthBackends(Enum):
},
}
PARSERS: ParserSettings = ParserSettings()
RUNSERVER_AUTH_BACKEND: AuthBackends = AuthBackends.BASIC
RUNSERVER_AUTH_BACKENDS: AuthBackends = AuthBackends([AuthBackend.BASIC])
RUNSERVER_AUTH_OIDC_AUDIENCE: str = None
RUNSERVER_AUTH_OIDC_ISSUER_URI: AnyHttpUrl = None
RUNSERVER_BACKEND: Literal[
Expand Down
Loading

0 comments on commit f44d39e

Please sign in to comment.