Skip to content

Commit

Permalink
Generation of API key on pbench-server (#3368)
Browse files Browse the repository at this point in the history
POST `/api/v1/key` call generates a unique API key for the authenticated user. 

Reworked `active_token` table to `api_keys`.

Co-authored-by: siddardh <sira@redhat27!>
  • Loading branch information
siddardh-ra and siddardh authored Apr 14, 2023
1 parent 6c8bb0a commit e6c3a54
Show file tree
Hide file tree
Showing 15 changed files with 585 additions and 197 deletions.
1 change: 1 addition & 0 deletions lib/pbench/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class API(Enum):
DATASETS_SEARCH = "datasets_search"
DATASETS_VALUES = "datasets_values"
ENDPOINTS = "endpoints"
KEY = "key"
SERVER_AUDIT = "server_audit"
SERVER_SETTINGS = "server_settings"
UPLOAD = "upload"
Expand Down
7 changes: 7 additions & 0 deletions lib/pbench/server/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pbench.common.exceptions import ConfigFileNotSpecified
from pbench.common.logger import get_pbench_logger
from pbench.server import PbenchServerConfig
from pbench.server.api.resources.api_key import APIKeyManage
from pbench.server.api.resources.datasets_inventory import DatasetsInventory
from pbench.server.api.resources.datasets_list import DatasetsList
from pbench.server.api.resources.datasets_metadata import DatasetsMetadata
Expand Down Expand Up @@ -123,6 +124,12 @@ def register_endpoints(api: Api, app: Flask, config: PbenchServerConfig):
endpoint="endpoints",
resource_class_args=(config,),
)
api.add_resource(
APIKeyManage,
f"{base_uri}/key",
endpoint="key",
resource_class_args=(config,),
)
api.add_resource(
ServerAudit,
f"{base_uri}/server/audit",
Expand Down
77 changes: 77 additions & 0 deletions lib/pbench/server/api/resources/api_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from http import HTTPStatus

from flask import jsonify
from flask.wrappers import Request, Response

from pbench.server import PbenchServerConfig
from pbench.server.api.resources import (
APIAbort,
ApiAuthorizationType,
ApiBase,
ApiContext,
APIInternalError,
ApiMethod,
ApiParams,
ApiSchema,
)
import pbench.server.auth.auth as Auth
from pbench.server.database.models.api_keys import APIKey, DuplicateApiKey
from pbench.server.database.models.audit import AuditType, OperationCode


class APIKeyManage(ApiBase):
def __init__(self, config: PbenchServerConfig):
super().__init__(
config,
ApiSchema(
ApiMethod.POST,
OperationCode.CREATE,
audit_type=AuditType.API_KEY,
audit_name="apikey",
authorization=ApiAuthorizationType.NONE,
),
)

def _post(
self, params: ApiParams, request: Request, context: ApiContext
) -> Response:
"""
Post request for generating a new persistent API key.
Required headers include
Content-Type: application/json
Accept: application/json
Returns:
Success: 201 with api_key
Raises:
APIAbort, reporting "UNAUTHORIZED"
APIInternalError, reporting the failure message
"""
user = Auth.token_auth.current_user()

if not user:
raise APIAbort(
HTTPStatus.UNAUTHORIZED,
"User provided access_token is invalid or expired",
)
try:
new_key = APIKey.generate_api_key(user)
except Exception as e:
raise APIInternalError(str(e)) from e

try:
key = APIKey(api_key=new_key, user=user)
key.add()
status = HTTPStatus.CREATED
except DuplicateApiKey:
status = HTTPStatus.OK
except Exception as e:
raise APIInternalError(str(e)) from e

context["auditing"]["attributes"] = {"key": new_key}
response = jsonify({"api_key": new_key})
response.status_code = status
return response
123 changes: 23 additions & 100 deletions lib/pbench/server/auth/auth.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
from datetime import datetime, timedelta, timezone
import enum
from http import HTTPStatus
from typing import Optional, Tuple
from typing import Optional

from flask import current_app, Flask, request
from flask_httpauth import HTTPTokenAuth
from flask_restful import abort
import jwt

from pbench.server import PbenchServerConfig
from pbench.server.auth import OpenIDClient, OpenIDTokenInvalid
from pbench.server.database.models.auth_tokens import AuthToken
from pbench.server.database.models.api_keys import APIKey
from pbench.server.database.models.users import User

# Module private constants
_TOKEN_ALG_INT = "HS256"

# Module public
token_auth = HTTPTokenAuth("Bearer")
oidc_client: OpenIDClient = None
Expand Down Expand Up @@ -53,43 +47,6 @@ def get_current_user_id() -> Optional[str]:
return str(user.id) if user else None


def encode_auth_token(time_delta: timedelta, user_id: int) -> Tuple[str, datetime]:
"""Generates an authorization token for an internal user ID.
Args:
time_delta : Token lifetime
user_id : Authorized user's internal ID
Returns:
A tuple containing the JWT token string and the absolute expiration time
"""
current_utc = datetime.now(timezone.utc)
expiration = current_utc + time_delta
payload = {
"iat": current_utc,
"exp": expiration,
"sub": user_id,
}
try:
auth_token = jwt.encode(
payload, current_app.secret_key, algorithm=_TOKEN_ALG_INT
)
except (
jwt.InvalidIssuer,
jwt.InvalidIssuedAtError,
jwt.InvalidAlgorithmError,
jwt.PyJWTError,
):
current_app.logger.exception(
"Could not encode the JWT auth token for user ID: {}", user_id
)
abort(
HTTPStatus.INTERNAL_SERVER_ERROR,
message="INTERNAL ERROR",
)
return auth_token, expiration


def get_auth_token() -> str:
"""Get the authorization token from the current request.
Expand Down Expand Up @@ -142,10 +99,7 @@ def verify_auth(auth_token: str) -> Optional[User]:
return None
user = None
try:
if oidc_client is not None:
user = verify_auth_oidc(auth_token)
else:
user = verify_auth_internal(auth_token)
user = verify_auth_oidc(auth_token)
except Exception as e:
current_app.logger.exception(
"Unexpected exception occurred while verifying the auth token {!r}: {}",
Expand All @@ -155,65 +109,31 @@ def verify_auth(auth_token: str) -> Optional[User]:
return user


class TokenState(enum.Enum):
"""The state of a token once decoded."""

INVALID = enum.auto()
EXPIRED = enum.auto()
VERIFIED = enum.auto()


def verify_internal_token(auth_token: str) -> TokenState:
"""Returns a TokenState depending on the state of the given token after
being decoded.
"""
try:
jwt.decode(
auth_token,
current_app.secret_key,
algorithms=_TOKEN_ALG_INT,
options={
"verify_signature": True,
"verify_aud": True,
"verify_exp": True,
},
)
except (jwt.InvalidSignatureError, jwt.DecodeError):
state = TokenState.INVALID
except jwt.ExpiredSignatureError:
state = TokenState.EXPIRED
else:
state = TokenState.VERIFIED
return state


def verify_auth_internal(auth_token_s: str) -> Optional[User]:
"""Validates the auth token of the current request.
Tries to validate the token as if it was generated by the Pbench server for
an internal user.
def verify_auth_api_key(api_key: str) -> Optional[User]:
"""Tries to validate the api_key that is generated by the Pbench server .
Args:
auth_token_s : authorization token string
api_key : authorization token string
Returns:
None if the token is not valid, a `User` object when the token is valid.
None if the api_key is not valid, a `User` object when the api_key is valid.
"""
state = verify_internal_token(auth_token_s)
if state == TokenState.VERIFIED:
auth_token = AuthToken.query(auth_token_s)
user = auth_token.user if auth_token else None
else:
user = None
return user
key = APIKey.query(api_key)
return key.user if key else None


def verify_auth_oidc(auth_token: str) -> Optional[User]:
"""Verify a token provided to the Pbench server which was obtained from a
third party identity provider.
"""Authorization token verification function.
The verification will pass either if the token is from a third-party OIDC
identity provider or if the token is a Pbench Server API key.
Note: Upon token introspection if we get a valid token, we import the
available user information from the token into our internal User database.
The function will first attempt to validate the token as an OIDC access token.
If that fails, it will then attempt to validate it as a Pbench Server API key.
If the token is a valid access token (and not if it is an API key),
we will import its contents into the internal user database.
Args:
auth_token : Token to authenticate
Expand All @@ -225,7 +145,10 @@ def verify_auth_oidc(auth_token: str) -> Optional[User]:
try:
token_payload = oidc_client.token_introspect(token=auth_token)
except OpenIDTokenInvalid:
pass
try:
user = verify_auth_api_key(auth_token)
except Exception:
pass
except Exception:
current_app.logger.exception(
"Unexpected exception occurred while verifying the auth token {}",
Expand Down
2 changes: 1 addition & 1 deletion lib/pbench/server/database/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
of the same is required here.
"""
from pbench.server.database.database import Database
from pbench.server.database.models.api_keys import APIKey # noqa F401
from pbench.server.database.models.audit import Audit # noqa F401
from pbench.server.database.models.auth_tokens import AuthToken # noqa F401
from pbench.server.database.models.datasets import Dataset, Metadata # noqa F401
from pbench.server.database.models.server_settings import ServerSetting # noqa F401
from pbench.server.database.models.templates import Template # noqa F401
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
""" Update table for storing api_key and removing auth_token
Revision ID: 80c8c690f09b
Revises: f628657bed56
Create Date: 2023-04-11 19:20:36.892126
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from pbench.server.database.models import TZDateTime

# revision identifiers, used by Alembic.
revision = "80c8c690f09b"
down_revision = "f628657bed56"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"api_keys",
sa.Column("api_key", sa.String(length=500), nullable=False),
sa.Column("created", TZDateTime(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("api_key"),
)
op.drop_index("ix_auth_tokens_expiration", table_name="auth_tokens")
op.drop_index("ix_auth_tokens_token", table_name="auth_tokens")
op.drop_table("auth_tokens")


def downgrade() -> None:
op.create_table(
"auth_tokens",
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column("token", sa.VARCHAR(length=500), autoincrement=False, nullable=False),
sa.Column(
"expiration", postgresql.TIMESTAMP(), autoincrement=False, nullable=False
),
sa.PrimaryKeyConstraint("id", name="auth_tokens_pkey"),
)
op.create_index("ix_auth_tokens_token", "auth_tokens", ["token"], unique=False)
op.create_index(
"ix_auth_tokens_expiration", "auth_tokens", ["expiration"], unique=False
)
op.drop_table("api_keys")
25 changes: 25 additions & 0 deletions lib/pbench/server/database/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import datetime
from typing import Callable

from sqlalchemy import DateTime
from sqlalchemy.exc import IntegrityError
from sqlalchemy.types import TypeDecorator


Expand Down Expand Up @@ -50,3 +52,26 @@ def process_result_value(self, value, dialect):
if value is not None:
value = value.replace(tzinfo=datetime.timezone.utc)
return value


def decode_integrity_error(
exception: IntegrityError, on_null: Callable, on_duplicate: Callable
) -> Exception:

"""Decode a SQLAlchemy IntegrityError to look for a recognizable UNIQUE
or NOT NULL constraint violation.
Return the original exception if it doesn't match.
Args:
exception : An IntegrityError to decode
Returns:
a more specific exception, or the original if decoding fails
"""
cause = exception.orig.args[-1]
if "UNIQUE constraint" in cause:
return on_duplicate(cause)
elif "NOT NULL constraint" in cause:
return on_null(cause)
return exception
Loading

0 comments on commit e6c3a54

Please sign in to comment.