Skip to content

Commit d49c8b8

Browse files
committed
✨(api) allow multiple authentication backends simultaneously
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).
1 parent cb8371d commit d49c8b8

File tree

19 files changed

+331
-194
lines changed

19 files changed

+331
-194
lines changed

.env.dist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ RALPH_BACKENDS__HTTP__LRS__STATEMENTS_ENDPOINT=/xAPI/statements
153153

154154
# LRS API
155155

156-
RALPH_RUNSERVER_AUTH_BACKEND=basic
156+
RALPH_RUNSERVER_AUTH_BACKENDS=basic
157157
RALPH_RUNSERVER_AUTH_OIDC_AUDIENCE=http://localhost:8100
158158
RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI=http://learning-analytics-playground_keycloak_1:8080/auth/realms/fun-mooc
159159
RALPH_RUNSERVER_BACKEND=es

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ have an authority field matching that of the user
5858
- Backends: Replace reference to a JSON column in ClickHouse with
5959
function calls on the String column [BC]
6060
- API: enhance 'limit' query parameter's validation
61+
- API: Variable `RUNSERVER_AUTH_BACKEND` becomes `RUNSERVER_AUTH_BACKENDS`, and
62+
multiple authentication methods are supported simultaneously
6163

6264
### Fixed
6365

docs/api.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,10 @@ $ curl --user [email protected]:PASSWORD http://localhost:8100/whoami
108108

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

111-
To enable OIDC auth, you should set the `RALPH_RUNSERVER_AUTH_BACKEND` environment variable as follows:
111+
112+
To enable OIDC auth, you should modify the `RALPH_RUNSERVER_AUTH_BACKENDS` environment variable by adding (or replacing by) `oidc`:
112113
```bash
113-
RALPH_RUNSERVER_AUTH_BACKEND=oidc
114+
RALPH_RUNSERVER_AUTH_BACKENDS=basic,oidc
114115
```
115116
and you should define the `RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI` environment variable with your identity provider's Issuer Identifier URI as follows:
116117
```bash

docs/backends.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Elasticsearch backend parameters required to connect to a cluster are:
159159

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

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

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

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

src/ralph/api/auth/__init__.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,51 @@
11
"""Main module for Ralph's LRS API authentication."""
2+
from typing import Optional
23

3-
from ralph.api.auth.basic import get_basic_auth_user
4+
from fastapi import Depends, HTTPException, status
5+
from fastapi.security import SecurityScopes
6+
7+
from ralph.api.auth.basic import AuthenticatedUser, get_basic_auth_user
48
from ralph.api.auth.oidc import get_oidc_user
5-
from ralph.conf import settings
9+
from ralph.conf import AuthBackend, settings
10+
11+
12+
def get_authenticated_user(
13+
security_scopes: SecurityScopes = SecurityScopes([]),
14+
basic_auth_user: Optional[AuthenticatedUser] = Depends(get_basic_auth_user),
15+
oidc_auth_user: Optional[AuthenticatedUser] = Depends(get_oidc_user),
16+
) -> AuthenticatedUser:
17+
"""Authenticate user with any allowed method, using credentials in the header."""
18+
if AuthBackend.BASIC not in settings.RUNSERVER_AUTH_BACKENDS:
19+
basic_auth_user = None
20+
if AuthBackend.OIDC not in settings.RUNSERVER_AUTH_BACKENDS:
21+
oidc_auth_user = None
22+
23+
if basic_auth_user:
24+
user = basic_auth_user
25+
auth_header = "Basic"
26+
elif oidc_auth_user:
27+
user = oidc_auth_user
28+
auth_header = "Bearer"
29+
else:
30+
auth_header = ",".join(
31+
[
32+
{"basic": "Basic", "oidc": "Bearer"}[backend.value]
33+
for backend in settings.RUNSERVER_AUTH_BACKENDS
34+
]
35+
)
36+
raise HTTPException(
37+
status_code=status.HTTP_401_UNAUTHORIZED,
38+
detail="Invalid authentication credentials",
39+
headers={"WWW-Authenticate": auth_header},
40+
)
641

7-
# At startup, select the authentication mode that will be used
8-
if settings.RUNSERVER_AUTH_BACKEND == settings.AuthBackends.OIDC:
9-
get_authenticated_user = get_oidc_user
10-
else:
11-
get_authenticated_user = get_basic_auth_user
42+
# Restrict access by scopes
43+
if settings.LRS_RESTRICT_BY_SCOPES:
44+
for requested_scope in security_scopes.scopes:
45+
if not user.scopes.is_authorized(requested_scope):
46+
raise HTTPException(
47+
status_code=status.HTTP_401_UNAUTHORIZED,
48+
detail=f'Access not authorized to scope: "{requested_scope}".',
49+
headers={"WWW-Authenticate": auth_header},
50+
)
51+
return user

src/ralph/api/auth/basic.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import bcrypt
1010
from cachetools import TTLCache, cached
1111
from fastapi import Depends, HTTPException, status
12-
from fastapi.security import HTTPBasic, HTTPBasicCredentials, SecurityScopes
12+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
1313
from pydantic import BaseModel, root_validator
1414
from starlette.authentication import AuthenticationError
1515

@@ -102,17 +102,15 @@ def get_stored_credentials(auth_file: Path) -> ServerUsersCredentials:
102102
@cached(
103103
TTLCache(maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_CACHE_TTL),
104104
lock=Lock(),
105-
key=lambda credentials, security_scopes: (
105+
key=lambda credentials: (
106106
credentials.username,
107107
credentials.password,
108-
security_scopes.scope_str,
109108
)
110109
if credentials is not None
111110
else None,
112111
)
113112
def get_basic_auth_user(
114113
credentials: Optional[HTTPBasicCredentials] = Depends(security),
115-
security_scopes: SecurityScopes = SecurityScopes([]),
116114
) -> AuthenticatedUser:
117115
"""Check valid auth parameters.
118116
@@ -121,18 +119,13 @@ def get_basic_auth_user(
121119
122120
Args:
123121
credentials (iterator): auth parameters from the Authorization header
124-
security_scopes: scopes requested for access
125122
126123
Raises:
127124
HTTPException
128125
"""
129126
if not credentials:
130-
logger.error("The basic authentication mode requires a Basic Auth header")
131-
raise HTTPException(
132-
status_code=status.HTTP_401_UNAUTHORIZED,
133-
detail="Could not validate credentials",
134-
headers={"WWW-Authenticate": "Basic"},
135-
)
127+
logger.debug("No credentials were found for Basic auth")
128+
return None
136129

137130
try:
138131
user = next(
@@ -185,13 +178,4 @@ def get_basic_auth_user(
185178

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

188-
# Restrict access by scopes
189-
if settings.LRS_RESTRICT_BY_SCOPES:
190-
for requested_scope in security_scopes.scopes:
191-
if not user.scopes.is_authorized(requested_scope):
192-
raise HTTPException(
193-
status_code=status.HTTP_401_UNAUTHORIZED,
194-
detail=f'Access not authorized to scope: "{requested_scope}".',
195-
headers={"WWW-Authenticate": "Basic"},
196-
)
197181
return user

src/ralph/api/auth/oidc.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import requests
88
from fastapi import Depends, HTTPException, status
9-
from fastapi.security import OpenIdConnect, SecurityScopes
9+
from fastapi.security import HTTPBearer, OpenIdConnect
1010
from jose import ExpiredSignatureError, JWTError, jwt
1111
from jose.exceptions import JWTClaimsError
1212
from pydantic import AnyUrl, BaseModel, Extra
@@ -94,8 +94,7 @@ def get_public_keys(jwks_uri: AnyUrl) -> Dict:
9494

9595

9696
def get_oidc_user(
97-
auth_header: Annotated[Optional[str], Depends(oauth2_scheme)],
98-
security_scopes: SecurityScopes = SecurityScopes([]),
97+
auth_header: Annotated[Optional[HTTPBearer], Depends(oauth2_scheme)],
9998
) -> AuthenticatedUser:
10099
"""Decode and validate OpenId Connect ID token against issuer in config.
101100
@@ -110,13 +109,12 @@ def get_oidc_user(
110109
Raises:
111110
HTTPException
112111
"""
113-
if auth_header is None or "Bearer" not in auth_header:
114-
logger.error("The OpenID Connect authentication mode requires a Bearer token")
115-
raise HTTPException(
116-
status_code=status.HTTP_401_UNAUTHORIZED,
117-
detail="Could not validate credentials",
118-
headers={"WWW-Authenticate": "Bearer"},
112+
if auth_header is None or "bearer" not in auth_header.lower():
113+
logger.debug(
114+
"Not using OIDC auth. The OpenID Connect authentication mode requires a "
115+
"Bearer token"
119116
)
117+
return None
120118

121119
id_token = auth_header.split(" ")[-1]
122120
provider_config = discover_provider(settings.RUNSERVER_AUTH_OIDC_ISSUER_URI)
@@ -151,14 +149,4 @@ def get_oidc_user(
151149
scopes=UserScopes(id_token.scope.split(" ") if id_token.scope else []),
152150
)
153151

154-
# Restrict access by scopes
155-
if settings.LRS_RESTRICT_BY_SCOPES:
156-
for requested_scope in security_scopes.scopes:
157-
if not user.scopes.is_authorized(requested_scope):
158-
raise HTTPException(
159-
status_code=status.HTTP_401_UNAUTHORIZED,
160-
detail=f'Access not authorized to scope: "{requested_scope}".',
161-
headers={"WWW-Authenticate": "Basic"},
162-
)
163-
164152
return user

src/ralph/backends/data/es.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class ESDataBackendSettings(BaseDataBackendSettings):
4545
DEFAULT_CHUNK_SIZE (int): The default chunk size for reading batches of
4646
documents.
4747
DEFAULT_INDEX (str): The default index to use for querying Elasticsearch.
48-
HOSTS (str or tuple): The comma separated list of Elasticsearch nodes to
48+
HOSTS (str or tuple): The comma-separated list of Elasticsearch nodes to
4949
connect to.
5050
LOCALE_ENCODING (str): The encoding used for reading/writing documents.
5151
POINT_IN_TIME_KEEP_ALIVE (str): The duration for which Elasticsearch should

src/ralph/cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161

6262

6363
class CommaSeparatedTupleParamType(click.ParamType):
64-
"""Comma separated tuple parameter type."""
64+
"""Comma-separated tuple parameter type."""
6565

6666
name = "value1,value2,value3"
6767

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

8282

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

8686
name = "key=value,key=value"
8787

@@ -123,7 +123,7 @@ def convert(self, value, param, ctx):
123123

124124

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

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

146146

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

150150
def __init__(self, headers_parameters_type: Any) -> None:
151151
"""Instantiate HeadersParametersParamType for a headers_parameters_type.

src/ralph/conf.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import sys
55
from enum import Enum
66
from pathlib import Path
7-
from typing import List, Sequence, Union
7+
from typing import List, Sequence, Tuple, Union
88

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

@@ -53,19 +53,19 @@ class Config(BaseSettingsConfig):
5353

5454

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

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

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

68-
raise TypeError("Invalid comma separated list")
68+
raise TypeError("Invalid comma-separated list")
6969

7070
yield validate
7171

@@ -133,6 +133,44 @@ class Config: # noqa: D106
133133
timeout: float
134134

135135

136+
class AuthBackend(str, Enum):
137+
"""Model for valid authentication methods."""
138+
139+
BASIC = "basic"
140+
OIDC = "oidc"
141+
142+
143+
class AuthBackends(Tuple[AuthBackend]):
144+
"""Model representing a tuple of authentication backends."""
145+
146+
@classmethod
147+
def __get_validators__(cls):
148+
"""Check whether the value is a comma-separated string or a tuple representing
149+
an AuthBackend.
150+
""" # noqa: D205
151+
152+
def validate(
153+
auth_backends: Union[
154+
str, AuthBackend, Tuple[AuthBackend], List[AuthBackend]
155+
]
156+
) -> Tuple[AuthBackend]:
157+
"""Check whether the value is a comma-separated string or a list/tuple."""
158+
if isinstance(auth_backends, str):
159+
return tuple(
160+
AuthBackend(value.lower()) for value in auth_backends.split(",")
161+
)
162+
163+
if isinstance(auth_backends, AuthBackend):
164+
return (auth_backends,)
165+
166+
if isinstance(auth_backends, (tuple, list)):
167+
return tuple(auth_backends)
168+
169+
raise TypeError("Invalid comma-separated list")
170+
171+
yield validate
172+
173+
136174
class Settings(BaseSettings):
137175
"""Pydantic model for Ralph's global environment & configuration settings."""
138176

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

145-
class AuthBackends(Enum):
146-
"""Enum of the authentication backends."""
147-
148-
BASIC = "basic"
149-
OIDC = "oidc"
150-
151183
_CORE: CoreSettings = core_settings
152184
AUTH_FILE: Path = _CORE.APP_DIR / "auth.json"
153185
AUTH_CACHE_MAX_SIZE = 100
@@ -188,7 +220,7 @@ class AuthBackends(Enum):
188220
},
189221
}
190222
PARSERS: ParserSettings = ParserSettings()
191-
RUNSERVER_AUTH_BACKEND: AuthBackends = AuthBackends.BASIC
223+
RUNSERVER_AUTH_BACKENDS: AuthBackends = AuthBackends("basic")
192224
RUNSERVER_AUTH_OIDC_AUDIENCE: str = None
193225
RUNSERVER_AUTH_OIDC_ISSUER_URI: AnyHttpUrl = None
194226
RUNSERVER_BACKEND: Literal[

0 commit comments

Comments
 (0)