diff --git a/src/auth/__init__.py b/src/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/auth.py b/src/auth/auth.py similarity index 95% rename from src/auth.py rename to src/auth/auth.py index 68d420d..793e69d 100644 --- a/src/auth.py +++ b/src/auth/auth.py @@ -1,5 +1,3 @@ -from urllib import parse - import requests from flask import current_app as app from flask import request @@ -9,15 +7,15 @@ AUTHENTICATION_HEADER, AuthenticateException, AuthHandler, - InvalidCredentialsException, -) -from .exceptions import ServerError + InvalidCredentialsException, ) +from ..exceptions import ServerError def get_user_id(): """Returns the user id from the auth cookie.""" auth_handler = AuthHandler(app.logger.debug) authentication = request.headers.get(AUTHENTICATION_HEADER, "") + app.logger.debug(f"Get user id from auth header: {authentication}") try: (user_id, _) = auth_handler.authenticate(authentication) except (AuthenticateException, InvalidCredentialsException): diff --git a/src/auth/claims.py b/src/auth/claims.py new file mode 100644 index 0000000..9e40497 --- /dev/null +++ b/src/auth/claims.py @@ -0,0 +1,21 @@ +from authlib.jose.errors import InvalidClaimError +from authlib.oauth2.rfc9068.claims import JWTAccessTokenClaims + + +class OpenSlidesAccessTokenClaims(JWTAccessTokenClaims): + REGISTERED_CLAIMS = JWTAccessTokenClaims.REGISTERED_CLAIMS + [ + 'sid', + 'userId' + ] + + def validate(self, **kwargs): + super().validate(**kwargs) + self._validate_claim_value('sid') + self._validate_claim_value('userId') + + def validate_typ(self): + # The resource server MUST verify that the 'typ' header value is 'at+jwt' + # or 'application/at+jwt' and reject tokens carrying any other value. + # -- Added jwt for keycloak compatibility + if self.header['typ'].lower() not in ('at+jwt', 'application/at+jwt', 'jwt'): + raise InvalidClaimError('typ') diff --git a/src/auth/token_validator.py b/src/auth/token_validator.py new file mode 100644 index 0000000..ccdf644 --- /dev/null +++ b/src/auth/token_validator.py @@ -0,0 +1,70 @@ +import requests +from authlib.jose import jwt, JsonWebKey +from authlib.jose.errors import DecodeError +from authlib.oauth2.rfc6750.errors import InvalidTokenError +from authlib.oauth2.rfc9068 import JWTBearerTokenValidator +from authlib.oidc.discovery import OpenIDProviderMetadata, get_well_known_url + +from .claims import OpenSlidesAccessTokenClaims + + +class JWTBearerOpenSlidesTokenValidator(JWTBearerTokenValidator): + # Cache the JWKS keys to avoid fetching them repeatedly + jwk_set = None + + def __init__(self, issuer, issuer_internal, resource_server, *args, **kwargs): + self.issuerInternal = issuer_internal + super().__init__(issuer, resource_server,*args, **kwargs) + + def get_jwks(self): + if self.jwk_set is None: + oidc_configuration = OpenIDProviderMetadata(requests.get(get_well_known_url(self.issuerInternal, True)).json()) + response = requests.get(oidc_configuration.get('jwks_uri')) + response.raise_for_status() + jwks_keys = response.json() + self.jwk_set = JsonWebKey.import_key_set(jwks_keys) + return self.jwk_set + + def authenticate_token(self, token_string): + + claims_options = { + 'iss': {'essential': True, 'validate': self.validate_iss}, + 'exp': {'essential': True}, + 'aud': {'essential': True, 'value': self.resource_server}, + 'sub': {'essential': True}, + 'client_id': {'essential': True}, + 'iat': {'essential': True}, + 'jti': {'essential': True}, + 'auth_time': {'essential': False}, + 'acr': {'essential': False}, + 'amr': {'essential': False}, + 'scope': {'essential': False}, + 'groups': {'essential': False}, + 'roles': {'essential': False}, + 'entitlements': {'essential': False}, + 'sid': {'essential': True}, + 'userId': {'essential': True}, + } + jwks = self.get_jwks() + + # If the JWT access token is encrypted, decrypt it using the keys and algorithms + # that the resource server specified during registration. If encryption was + # negotiated with the authorization server at registration time and the incoming + # JWT access token is not encrypted, the resource server SHOULD reject it. + + # The resource server MUST validate the signature of all incoming JWT access + # tokens according to [RFC7515] using the algorithm specified in the JWT 'alg' + # Header Parameter. The resource server MUST reject any JWT in which the value + # of 'alg' is 'none'. The resource server MUST use the keys provided by the + # authorization server. + try: + return jwt.decode( + token_string, + key=jwks, + claims_cls=OpenSlidesAccessTokenClaims, + claims_options=claims_options, + ) + except DecodeError: + raise InvalidTokenError( + realm=self.realm, extra_attributes=self.extra_attributes + ) diff --git a/src/mediaserver.py b/src/mediaserver.py index 6859732..4079089 100644 --- a/src/mediaserver.py +++ b/src/mediaserver.py @@ -4,49 +4,48 @@ import sys from signal import SIGINT, SIGTERM, signal -import requests -from authlib.jose import JsonWebKey, jwt -from authlib.oauth2.rfc9068 import JWTBearerTokenValidator -from authlib.oidc.discovery import get_well_known_url, OpenIDProviderMetadata +from authlib.integrations.flask_oauth2 import ResourceProtector +from authlib.oauth2 import OAuth2Error from flask import Flask, Response, jsonify, redirect, request +from flask import json -from .auth import AUTHENTICATION_HEADER, check_file_id, check_login +from .auth.auth import AUTHENTICATION_HEADER, check_file_id, check_login +from .auth.token_validator import JWTBearerOpenSlidesTokenValidator from .config_handling import init_config, is_dev_mode from .database import Database from .exceptions import BadRequestError, HttpError, NotFoundError from .logging import init_logging -from authlib.integrations.flask_oauth2 import ResourceProtector, current_token +import os cache = {} -# Keycloak settings -KEYCLOAK_DOMAIN = 'http://keycloak:8080' -KEYCLOAK_REALM = 'os' -ISSUER = f"{KEYCLOAK_DOMAIN}/realms/{KEYCLOAK_REALM}" - -class MyBearerTokenValidator(JWTBearerTokenValidator): - # Cache the JWKS keys to avoid fetching them repeatedly - jwk_set = None - - def get_jwks_key_set(self): - if self.jwk_set is None: - oidc_configuration = OpenIDProviderMetadata(requests.get(get_well_known_url(ISSUER)).json()) - response = requests.get(oidc_configuration.get('jwks_uri')) - response.raise_for_status() - jwks_keys = response.json() - self.jwk_set = JsonWebKey.import_key_set(jwks_keys) - return self.jwk_set - - def verify_token(self, token): - try: - claims = jwt.decode(token, key=self.get_jwks_key_set()) - claims.validate() - return claims - except Exception as e: - return None +KEYCLOAK_REALM = os.environ.get("OPENSLIDES_AUTH_REALM") +KEYCLOAK_URL = os.environ.get("OPENSLIDES_KEYCLOAK_URL") +ISSUER_REAL = os.environ.get("OPENSLIDES_TOKEN_ISSUER") + +assert ISSUER_REAL is not None, "OPENSLIDES_TOKEN_ISSUER must be set in environment" +assert KEYCLOAK_REALM is not None, "OPENSLIDES_AUTH_REALM must be set in environment" +assert KEYCLOAK_URL is not None, "OPENSLIDES_KEYCLOAK_URL must be set in environment" + +ISSUER_INTERNAL = f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}" + + +# class MyCustomResourceProtector(ResourceProtector): + + # def validate_request(self, scopes, request, **kwargs): + # """Validate the request and return a token.""" + # validator, token_string = self.parse_request_authorization(request) + # app.logger.debug(f"Validating request with {validator} and {token_string}") + # validator.validate_request(request) + # app.logger.debug(f"Request validated") + # token = validator.authenticate_token(token_string) + # app.logger.debug(f"Token authenticated: {token}") + # validator.validate_token(token, scopes, request, **kwargs) + # app.logger.debug(f"Token validated") + # return token require_oauth = ResourceProtector() -require_oauth.register_token_validator(MyBearerTokenValidator(ISSUER, 'https://localhost:8000/system')) +require_oauth.register_token_validator(JWTBearerOpenSlidesTokenValidator(ISSUER_REAL, ISSUER_INTERNAL, 'os')) app = Flask(__name__) with app.app_context(): @@ -54,19 +53,36 @@ def verify_token(self, token): init_config() database = Database() +app.debug = True app.logger.info("Started media server") -@app.errorhandler(HttpError) +@app.errorhandler(Exception) def handle_view_error(error): - app.logger.error( - f"Request to {request.path} resulted in {error.status_code}: " - f"{error.message}" - ) - res_content = {"message": f"Media-Server: {error.message}"} - response = jsonify(res_content) - response.status_code = error.status_code - return response + if isinstance(error, HttpError): + app.logger.error( + f"Request to {request.path} resulted in {error.status_code}: " + f"{error.message}" + ) + res_content = {"message": f"Media-Server: {error.message}"} + response = jsonify(res_content) + response.status_code = error.status_code + return response + elif isinstance(error, OAuth2Error): + app.logger.error( + f"Request to {request.path} resulted in {error.status_code}: " + f"{error.description} (AuthlibHTTPError)" + ) + res_content = {"message": f"Media-Server: {error.description}"} + response = jsonify(res_content) + response.status_code = error.status_code + return response + else: + app.logger.error(f"Request to {request.path} resulted in {error} ({type(error)})") + res_content = {"message": "Media-Server: Internal Server Error"} + response = jsonify(res_content) + response.status_code = 500 + return response @app.route("/system/media/get/")