Skip to content

Commit

Permalink
Add OIDC user to the functional test (#3235)
Browse files Browse the repository at this point in the history
* Use OIDC user for the functional test

Functional tests should move to using OIDC tokens by making an Admin REST API request to the OIDC server (Keycloak) directly as the server won't be providing Register/Login capabilities on its endpoints.

- Create a new scope in the Keycloak to add OIDC client name in aud claim (required for the authentication)
- Functional test user registration happens directly with the OIDC server using the Admin token
- Functional test user makes a REST API call to get the OIDC token

PBENCH-1070
  • Loading branch information
npalaska authored Feb 21, 2023
1 parent 19b20b7 commit ffc8c5d
Show file tree
Hide file tree
Showing 16 changed files with 315 additions and 310 deletions.
46 changes: 23 additions & 23 deletions lib/pbench/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import requests
from requests.structures import CaseInsensitiveDict

from pbench.client.types import Dataset, JSONMap, JSONOBJECT
from pbench.client.oidc_admin import OIDCAdmin
from pbench.client.types import Dataset, JSONOBJECT


class PbenchClientError(Exception):
Expand Down Expand Up @@ -86,6 +87,7 @@ def __init__(self, host: str):
self.auth_token: Optional[str] = None
self.session: Optional[requests.Session] = None
self.endpoints: Optional[JSONOBJECT] = None
self.oidc_admin: Optional[OIDCAdmin] = None

def _headers(
self, user_headers: Optional[dict[str, str]] = None
Expand Down Expand Up @@ -302,8 +304,13 @@ def delete(
return response

def connect(self, headers: Optional[dict[str, str]] = None) -> None:
"""Connect to the Pbench Server host using the endpoints API to be sure
that it responds, and cache the endpoints response payload.
"""Performs some pre-requisite actions to make server client usable.
1. Connect to the Pbench Server host using the endpoints API to be
sure that it responds, and cache the endpoints response payload.
2. Create an OIDCAdmin object that a server client can use to
perform privileged actions on an OIDC server.
This also allows the client to add default HTTP headers to the session
which will be used for all operations unless overridden for specific
Expand All @@ -321,31 +328,24 @@ def connect(self, headers: Optional[dict[str, str]] = None) -> None:
self.endpoints = response.json()
assert self.endpoints

def login(self, user: str, password: str) -> JSONMap:
"""Login to a specified username with the password, and store the
resulting authentication token.
# Create an OIDCAdmin object and confirm the connection was successful
self.oidc_admin = OIDCAdmin(server_url=self.endpoints["openid"]["server"])

def login(self, user: str, password: str):
"""Log into the OIDC server using the specified username and password,
and store the resulting authentication token.
Args:
user: Account username
password: Account password
Returns:
The login response
"""
response = self.post(API.LOGIN, json={"username": user, "password": password})
response.raise_for_status()
json = response.json()
self.username = json["username"]
self.auth_token = json["auth_token"]
return JSONMap(json)

def logout(self) -> None:
"""Logout the currently authenticated user and remove the
authentication token.
"""
self.post(API.LOGOUT)
self.username = None
self.auth_token = None
response = self.oidc_admin.user_login(
client_id=self.endpoints["openid"]["client"],
username=user,
password=password,
)
self.username = user
self.auth_token = response["access_token"]

def upload(self, tarball: Path, **kwargs) -> requests.Response:
"""Upload a tarball to the server.
Expand Down
141 changes: 141 additions & 0 deletions lib/pbench/client/oidc_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from http import HTTPStatus
import os

import requests

from pbench.server.auth import Connection


class OIDCAdmin(Connection):
OIDC_REALM = os.getenv("OIDC_REALM", "pbench-server")
ADMIN_USERNAME = os.getenv("OIDC_ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("OIDC_ADMIN_PASSWORD", "admin")

def __init__(self, server_url: str):
super().__init__(server_url, verify=False)

def get_admin_token(self) -> dict:
"""pbench-server realm admin user login.
Returns:
access_token json payload
{ 'access_token': <access_token>,
'expires_in': 60, 'refresh_expires_in': 1800,
'refresh_token': <refresh_token>,
'token_type': 'Bearer',
'not-before-policy': 0,
'session_state': '8f558797-50e7-496d-bb45-3b5ac9fdcddb',
'scope': 'profile email'}
"""
url_path = "/realms/master/protocol/openid-connect/token"
data = {
"grant_type": "password",
"client_id": "admin-cli",
"username": self.ADMIN_USERNAME,
"password": self.ADMIN_PASSWORD,
}
return self.post(path=url_path, data=data).json()

def create_new_user(
self,
username: str,
email: str,
password: str,
first_name: str = "",
last_name: str = "",
) -> requests.Response:
"""Creates a new user under the OIDC_REALM.
Note: This involves a REST API call to the
OIDC server to create a new user.
Args:
username: username to register,
email: user email address,
password: user password,
first_name: Optional first name of the user,
last_name: Optional first name of the user,
Returns:
Response from the request.
"""
admin_token = self.get_admin_token().get("access_token")
url_path = f"/admin/realms/{self.OIDC_REALM}/users"
headers = {"Authorization": f"Bearer {admin_token}"}
data = {
"username": username,
"email": email,
"emailVerified": True,
"enabled": True,
"firstName": first_name,
"lastName": last_name,
"credentials": [
{"type": "password", "value": password, "temporary": False}
],
}
response = self.post(path=url_path, json=data, headers=headers)
return response

def user_login(self, client_id: str, username: str, password: str) -> dict:
"""pbench-server realm user login on a specified client.
Args:
client_id: client_name to use in the request
username: username of the user logging in
password: OIDC password
Returns:
access_token json payload
{ 'access_token': <access_token>,
'expires_in': 60, 'refresh_expires_in': 1800,
'refresh_token': <refresh_token>,
'token_type': 'Bearer',
'not-before-policy': 0,
'session_state': '8f558797-50e7-496d-bb45-3b5ac9fdcddb',
'scope': 'profile email'}
"""
url_path = f"/realms/{self.OIDC_REALM}/protocol/openid-connect/token"
data = {
"client_id": client_id,
"grant_type": "password",
"scope": "profile email",
"username": username,
"password": password,
}
return self.post(path=url_path, data=data).json()

def get_user(self, username: str, token: str) -> dict:
"""Get the OIDC user representation dict.
Args:
username: username to query
token: access_token string to validate
Returns:
User dict representation
{'id': '37117992-a3de-43f7-b844-e6ee178e9965',
'createdTimestamp': 1675981768951,
'username': 'admin',
'enabled': True,
'totp': False,
'emailVerified': False,
'disableableCredentialTypes': [],
'requiredActions': [],
'notBefore': 0,
'access': {'manageGroupMembership': True, 'view': True, 'mapRoles': True, 'impersonate': True, 'manage': True}
...
}
"""
response = self.get(
f"admin/realms/{self.OIDC_REALM}/users",
headers={"Authorization": f"Bearer {token}"},
username=username,
)
if response.status_code == HTTPStatus.OK:
return response.json()[0]
return {}
20 changes: 6 additions & 14 deletions lib/pbench/server/api/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1231,13 +1231,6 @@ def _check_authorization(self, mode: ApiAuthorization):
user_id = mode.user
role = mode.role
authorized_user: User = Auth.token_auth.current_user()
username = "none"
if user_id:
user = User.query(id=user_id)
if user:
username = user.username
else:
current_app.logger.error("User ID {} not found", user_id)

# The ADMIN authorization doesn't involve a target resource owner or
# access, so take care of that first as a special case. If there is
Expand All @@ -1260,10 +1253,9 @@ def _check_authorization(self, mode: ApiAuthorization):
access = mode.access

current_app.logger.debug(
"Authorizing {} access for {} to user {} ({}) with access {} using {}",
"Authorizing {} access for {} to user (user id: {}) with access {} using {}",
role,
authorized_user,
username,
user_id,
mode.access,
mode.type,
Expand All @@ -1290,12 +1282,12 @@ def _check_authorization(self, mode: ApiAuthorization):
# An unauthenticated user is never allowed to access private
# data nor to perform an potential mutation of data: REJECT
current_app.logger.warning(
"Attempt to {} user {} data without login", role, username
"Attempt to {} user {} data without login", role, user_id
)
raise UnauthorizedAccess(
authorized_user,
role,
username,
user_id,
access,
HTTPStatus.UNAUTHORIZED,
)
Expand All @@ -1308,7 +1300,7 @@ def _check_authorization(self, mode: ApiAuthorization):
role,
)
raise UnauthorizedAccess(
authorized_user, role, username, access, HTTPStatus.FORBIDDEN
authorized_user, role, user_id, access, HTTPStatus.FORBIDDEN
)
elif (
user_id
Expand All @@ -1322,10 +1314,10 @@ def _check_authorization(self, mode: ApiAuthorization):
"Unauthorized attempt by {} to {} user {} data",
authorized_user,
role,
username,
user_id,
)
raise UnauthorizedAccess(
authorized_user, role, username, access, HTTPStatus.FORBIDDEN
authorized_user, role, user_id, access, HTTPStatus.FORBIDDEN
)
else:
# We have determined that there is an authenticated user with
Expand Down
Loading

0 comments on commit ffc8c5d

Please sign in to comment.