Skip to content

Commit

Permalink
allow multipe auth after unification
Browse files Browse the repository at this point in the history
  • Loading branch information
Leobouloc committed Oct 31, 2023
1 parent ce0e618 commit a4802c9
Show file tree
Hide file tree
Showing 17 changed files with 276 additions and 212 deletions.
4 changes: 2 additions & 2 deletions .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ RALPH_BACKENDS__HTTP__LRS__STATEMENTS_ENDPOINT=/xAPI/statements

# RALPH_CONVERTER_EDX_XAPI_UUID_NAMESPACE=

# LRS API
# 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 @@ -51,6 +51,8 @@ have an authority field matching that of the user
- Helm: improve volumes and ingress configurations
- API: Add `RALPH_LRS_RESTRICT_BY_SCOPE` option enabling endpoint access
control by user scopes
- 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) `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
1 change: 1 addition & 0 deletions src/ralph/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def filter_transactions(
)

app = FastAPI()

app.include_router(statements.router)
app.include_router(health.router)

Expand Down
49 changes: 43 additions & 6 deletions src/ralph/api/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,48 @@
"""Main module for Ralph's LRS API authentication."""

from fastapi import Depends, HTTPException, status
from fastapi.security import SecurityScopes

from ralph.api.auth.basic import 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=Depends(get_basic_auth_user),
oidc_auth_user=Depends(get_oidc_user),
):
"""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 is not None:
user = basic_auth_user
auth_method = "Basic"
elif oidc_auth_user is not None:
user = oidc_auth_user
auth_method = "Bearer"
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={
"WWW-Authenticate": ",".join(
[val.value for val in settings.RUNSERVER_AUTH_BACKENDS]
)
},
)

# 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_method},
)
return user
47 changes: 11 additions & 36 deletions src/ralph/api/auth/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

import bcrypt
from cachetools import TTLCache, cached
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials, SecurityScopes
from fastapi import Depends
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.info("No credentials were found for Basic auth")
return None

try:
user = next(
Expand All @@ -145,15 +138,14 @@ def get_basic_auth_user(
except StopIteration:
# next() gets the first item in the enumerable; if there is none, it
# raises a StopIteration error as it is out of bounds.
logger.warning(
logger.info(
"User %s tried to authenticate but this account does not exists",
credentials.username,
)
hashed_password = None
except AuthenticationError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)
) from exc
except AuthenticationError:
logger.info("Error while authenticating using Basic auth")
return None

# Check that a password was passed
if not hashed_password:
Expand All @@ -162,11 +154,7 @@ def get_basic_auth_user(
bcrypt.checkpw(
credentials.password.encode(settings.LOCALE_ENCODING), UNUSED_PASSWORD
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Basic"},
)
return None

# Check password validity
if not bcrypt.checkpw(
Expand All @@ -177,21 +165,8 @@ def get_basic_auth_user(
"Authentication failed for user %s",
credentials.username,
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Basic"},
)
return None

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
47 changes: 20 additions & 27 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 @@ -66,7 +66,7 @@ def discover_provider(base_url: AnyUrl) -> Dict:
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
detail="Could not validate credentials ABU",
headers={"WWW-Authenticate": "Bearer"},
) from exc

Expand All @@ -88,14 +88,13 @@ def get_public_keys(jwks_uri: AnyUrl) -> Dict:
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
detail="Could not validate credentials ABA",
headers={"WWW-Authenticate": "Bearer"},
) from exc


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 @@ -109,17 +108,25 @@ 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"},
logger.info(
"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)
key = get_public_keys(provider_config["jwks_uri"])
try:
provider_config = discover_provider(settings.RUNSERVER_AUTH_OIDC_ISSUER_URI)
except HTTPException:
return None

try:
key = get_public_keys(provider_config["jwks_uri"])
except HTTPException:
return None

algorithms = provider_config["id_token_signing_alg_values_supported"]
audience = settings.RUNSERVER_AUTH_OIDC_AUDIENCE
options = {
Expand All @@ -137,11 +144,7 @@ def get_oidc_user(
)
except (ExpiredSignatureError, JWTError, JWTClaimsError) as exc:
logger.error("Unable to decode the ID token: %s", exc)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
) from exc
return None

id_token = IDToken.parse_obj(decoded_token)

Expand All @@ -150,14 +153,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
38 changes: 31 additions & 7 deletions src/ralph/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,36 @@ class Config: # pylint: disable=missing-class-docstring # noqa: D106
timeout: float


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

BASIC = "Basic"
OIDC = "OIDC"


class AuthBackends(str):
"""Model representing a list of authentication backends."""

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

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

if isinstance(value, str):
return tuple(AuthBackend(val) for val in value.split(","))

raise TypeError("Invalid comma separated list")

yield validate


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

Expand All @@ -142,12 +172,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 +212,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 a4802c9

Please sign in to comment.