Skip to content

Commit

Permalink
✨(authentication) add OpenId Connect authentication
Browse files Browse the repository at this point in the history
Add ID tokens verification issued by a third party authentication server
(like Keycloak or Okta).
Ralph user can specify in the .env file the authentication method that will
be called at each request by setting the variable RALPH_RUNSERVER_AUTH_BACKEND
to either `basic` or `oidc`.
  • Loading branch information
wilbrdt committed Jun 13, 2023
1 parent 7b41fa6 commit 8c1292d
Show file tree
Hide file tree
Showing 20 changed files with 886 additions and 202 deletions.
3 changes: 3 additions & 0 deletions .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ RALPH_BACKENDS__DATABASE__CLICKHOUSE__TEST_TABLE_NAME=test_xapi_events_all

# LRS API

RALPH_RUNSERVER_AUTH_BACKEND=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
RALPH_RUNSERVER_HOST=0.0.0.0
RALPH_RUNSERVER_MAX_SEARCH_HITS_COUNT=100
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to
- Implement xAPI virtual classroom pydantic models
- Allow to insert custom endpoint url for S3 service
- Cache the HTTP Basic auth credentials to improve API response time
- Support OpenID Connect authentication method

### Changed

Expand Down
91 changes: 88 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ Ralph comes with an API server that aims to implement the Learning Record Store

## Getting started

The API server supports the following authentication methods:

- Http Basic Authentication (default method)
- OpenID Connect authentication on top of OAuth2.0

### HTTP Basic Authentication

The default method for securing Ralph API server is with HTTP basic authentication.

The API server can be started up with the following command:

```bash
Expand All @@ -18,7 +27,7 @@ documentation](./backends.md) for more details.
However, before you can start your API server and make requests against it, you
need to set up your credentials.

### Creating a credentials file
#### Creating a credentials file

The credentials file is expected to be a valid JSON file. Its location is
specified by the `RALPH_AUTH_FILE` configuration value. By default, `ralph`
Expand Down Expand Up @@ -61,7 +70,11 @@ This command updates your credentials file with the new `janedoe` user.
> optional dependencies, _e.g._ `pip install ralph-malph[cli]` (which we highly
> recommend).
### Making a GET request
#### Scopes

(Work In Progress)

#### Making a GET request

The first request that can be answered by the ralph API server is a `whoami` request, which checks if the user is authenticated and returns their username and permission scopes.

Expand All @@ -81,7 +94,79 @@ $ curl --user john.doe@example.com:PASSWORD http://localhost:8100/whoami
< {"username":"john.doe@example.com","scopes":["authenticated","example_scope"]}
```

### Forwarding statements
### OpenID Connect authentication

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:
```bash
RALPH_RUNSERVER_AUTH_BACKEND=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
RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI=http://{provider_host}:{provider_port}/auth/realms/{realm_name}
```

This address must be accessible to the LRS on startup as it will perform OIDC Discovery to retrieve public keys and other information about the OIDC environment.

It is also strongly recommended that you set the optional `RALPH_RUNSERVER_AUTH_OIDC_AUDIENCE` environment variable to the origin address of the LRS itself (ex. "http://localhost:8100") to enable verification that a given token was issued specifically for the LRS.

#### Identity Providers

OIDC support is currently developed and tested against [Keycloak](https://www.keycloak.org/) but may work with other identity providers that implement the specification.

The [Learning analytics playground](https://github.com/openfun/learning-analytics-playground/) repository contains a Docker Compose file and configuration for a demo instance of Keycloak with a `ralph` client.

#### Scopes

(Work In Progress)

#### Making a GET request

The first request that can be answered by the ralph API server is a `whoami` request, which checks if the user is authenticated and returns their username and permission scopes.

Use curl to get `http://localhost:8100/whoami`:

```bash
$ curl http://localhost:8100/whoami
< HTTP/1.1" 401 Unauthorized
< {"detail":"Could not validate credentials"}
```
With the Keycloak instance running, use curl to get access token from Keycloak:
```bash
curl --request POST 'http://localhost:8080/auth/realms/fun-mooc/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=ralph' \
--data-urlencode 'client_secret=super-secret' \
--data-urlencode 'username=ralph_admin' \
--data-urlencode 'password=funfunfun' \
--data-urlencode 'grant_type=password'
```
which outputs (tokens truncated for example purpose):
```json
{
"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJSTWlLM",
"expires_in":300,
"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI4MDc5NjExM",
"token_type":"Bearer",
"not-before-policy":0,
"session_state":"22a36735-e35f-496b-a243-152d32ebff45",
"scope":"profile email"
}
```
Send the access token to the API server as a Bearer header:
```bash
$ curl http://localhost:8100/whoami --header "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJSTWlLM"
< HTTP/1.1 200 OK
< {"username":"ralph_admin","scopes":["all"]}
```
## Forwarding statements
Ralph's API server can be configured to forward xAPI statements it receives to other
LRSes.
Expand Down
5 changes: 4 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ cli =
dev =
bandit==1.7.5
black==23.3.0
cryptography==40.0.1
factory-boy==3.2.1
flake8==6.0.0
hypothesis==6.78.1
Expand All @@ -84,8 +85,9 @@ dev =
pylint==2.17.4
pytest==7.3.2
pytest-asyncio==0.21.0
pytest-cov==4.0.0
pytest-cov==4.1.0
pytest-httpx==0.22.0
responses==0.23.1
ci =
twine==4.0.2
lrs =
Expand All @@ -99,6 +101,7 @@ lrs =
h11>=0.11.0
httpx==0.24.1
sentry_sdk==1.25.1
python-jose==3.3.0
uvicorn[standard]==0.22.0

[options.packages.find]
Expand Down
7 changes: 5 additions & 2 deletions src/ralph/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from ralph.conf import settings

from .. import __version__
from .auth import AuthenticatedUser, authenticated_user
from .auth import get_authenticated_user
from .auth.user import AuthenticatedUser
from .routers import health, statements


Expand Down Expand Up @@ -44,6 +45,8 @@ def filter_transactions(event, hint): # pylint: disable=unused-argument


@app.get("/whoami")
async def whoami(user: AuthenticatedUser = Depends(authenticated_user)):
async def whoami(
user: AuthenticatedUser = Depends(get_authenticated_user),
):
"""Return the current user's username along with their scopes."""
return {"username": user.username, "scopes": user.scopes}
12 changes: 12 additions & 0 deletions src/ralph/api/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Main module for Ralph's LRS API authentication."""

from ralph.api.auth.basic import get_authenticated_user as get_basic_user
from ralph.api.auth.oidc import get_authenticated_user as get_oidc_user
from ralph.conf import settings

# At startup, select the authentication mode that will be used
get_authenticated_user = (
get_oidc_user
if settings.RUNSERVER_AUTH_BACKEND == settings.AuthBackends.OIDC
else get_basic_user
)
41 changes: 24 additions & 17 deletions src/ralph/api/auth.py → src/ralph/api/auth/basic.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Authentication & authorization related tools for the Ralph API."""
"""Basic authentication & authorization related tools for the Ralph API."""

import logging
from functools import lru_cache
from pathlib import Path
from threading import Lock
from typing import List
from typing import List, Union

import bcrypt
from cachetools import TTLCache, cached
Expand All @@ -13,6 +13,7 @@
from pydantic import BaseModel, root_validator
from starlette.authentication import AuthenticationError

from ralph.api.auth.user import AuthenticatedUser
from ralph.conf import settings

# Unused password used to avoid timing attacks, by comparing passwords supplied
Expand All @@ -21,24 +22,12 @@
UNUSED_PASSWORD = bcrypt.hashpw(b"ralph", bcrypt.gensalt())


security = HTTPBasic()
security = HTTPBasic(auto_error=False)

# API auth logger
logger = logging.getLogger(__name__)


class AuthenticatedUser(BaseModel):
"""Pydantic model for user authentication.
Attributes:
username (str): Consists of the username of the current user.
scopes (List): Consists of the scopes the user has access to.
"""

username: str
scopes: List[str]


class UserCredentials(AuthenticatedUser):
"""Pydantic model for user credentials as stored in the credentials file.
Expand Down Expand Up @@ -116,9 +105,13 @@ def get_stored_credentials(auth_file: Path) -> ServerUsersCredentials:
key=lambda credentials: (
credentials.username,
credentials.password,
),
)
if credentials is not None
else None,
)
def authenticated_user(credentials: HTTPBasicCredentials = Depends(security)):
def get_authenticated_user(
credentials: Union[HTTPBasicCredentials, None] = Depends(security),
) -> AuthenticatedUser:
"""Checks valid auth parameters.
Gets the basic auth parameters from the Authorization header, and checks them
Expand All @@ -127,7 +120,21 @@ def authenticated_user(credentials: HTTPBasicCredentials = Depends(security)):
Args:
credentials (iterator): auth parameters from the Authorization header
Return:
AuthenticatedUser (AuthenticatedUser)
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"},
)

try:
user = next(
filter(
Expand Down
Loading

0 comments on commit 8c1292d

Please sign in to comment.