diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7da590064c8a1c08297461f57497145308c192c4..6b9a8483fe2c041f55254817dac0ce32b29211a3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,6 +19,17 @@ consent_provider: - consent_provider/**/* - .gitlab-ci.yml +logout_provider: + stage: build + variables: + KANIKO_CONTEXT: "logout_provider" + KANIKO_BUILD_IMAGENAME: $CI_JOB_NAME + extends: .kaniko_build + only: + changes: + - logout_provider/**/* + - .gitlab-ci.yml + login_provider: stage: build variables: @@ -74,6 +85,8 @@ behave-integration: alias: login - name: ${CI_REGISTRY_IMAGE}/consent_provider:${CI_COMMIT_REF_NAME} alias: consent + - name: ${CI_REGISTRY_IMAGE}/logout_provider:${CI_COMMIT_REF_NAME} + alias: logout - name: oryd/hydra:latest alias: hydra command: @@ -81,11 +94,11 @@ behave-integration: - all - --dangerous-force-http - --dangerous-allow-insecure-redirect-urls - - http://oauth:5000/login/sso/authorized + - "http://oidc:5000/callback" - name: open.greenhost.net:4567/openappstack/user-panel/backend:master alias: backend - name: ${CI_REGISTRY_IMAGE}/integration_test_app:${CI_COMMIT_REF_NAME} - alias: oauth + alias: oidc variables: # Feature Flag FF_NETWORK_PER_BUILD Enables creation of a docker network per build # with the docker executor of the gitlab-runner. This is required for service @@ -93,9 +106,7 @@ behave-integration: FF_NETWORK_PER_BUILD: 1 DATABASE_HOST: "postgres" URLS_LOGIN: "http://login:5000/login" - URLS_LOGOUT: "http://login:5000/logout" - LOGOUT_URL: "http://login/logout" - URLS_POST_LOGOUT_REDIRECT: "http://login:5000/" + URLS_LOGOUT: "http://logout:5002/logout" URLS_CONSENT: "http://consent:5001/consent" URLS_SELF_ISSUER: "http://hydra:4444/" BASE_URL: "http://hydra:4444/" @@ -113,8 +124,6 @@ behave-integration: ROLE: "admin" DSN: "memory" SECRETS_SYSTEM: "youReallyNeedToChangeThis" - OIDC_SUBJECT_TYPES_SUPPORTED: "public,pairwise" - OIDC_SUBJECT_TYPE_PAIRWISE_SALT: "youReallyNeedToChangeThis" DATABASE_USER: postgres DATABASE_PASSWORD: secret DATABASE_NAME: postgres @@ -137,12 +146,12 @@ behave-integration: - /bin/bash user-panel/backend/utils/assign-role.bash ${TESTUSER_USERNAME} ${ROLE} backend:5000 # Wait for 60s for hydra to become available. Then create the oauth2 client object - while [[ $HYDRASTATUS -ne "200" && 60 -ge $TIMER ]]; do HYDRASTATUS=`curl http://hydra:4445/health/ready -o /dev/null -w "%{http_code}"` || TIMER=$TIMER+5 && sleep 5 ; done - - /bin/bash test/create-hydra-client.bash ${KEY} ${SECRET} http://hydra:4445 http://oauth:5000/login/sso/authorized + - /bin/bash test/create-hydra-client.bash ${KEY} ${SECRET} http://hydra:4445 http://oidc:5000/callback http://oidc:5000/ http://oidc:5000/logout - cd test/integration_tests/test/behave/ - > python3 -m behave -D headless=True - -D url=http://oauth:5000 + -D url=http://oidc:5000 -D username=${TESTUSER_USERNAME} -D username2=${TESTUSER_USERNAME2} -D password=${TESTUSER_PASSWORD} diff --git a/docker-compose.yml b/docker-compose.yml index 260a4125d2335e5b2685f2234e7d014f239e3a59..546bb2d6ca9b92e554342cdc968d7e9a47a276f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,13 +13,12 @@ services: - "4445:4445" # Admin port - "5555:5555" # Port for hydra token user command: - serve all --dangerous-force-http --dangerous-allow-insecure-redirect-urls "http://127.0.0.1:13337/login/sso/authorized, http://localhost:3000/login/sso/authorized" + serve all --dangerous-force-http --dangerous-allow-insecure-redirect-urls "http://127.0.0.1:13337/callback, http://127.0.0.1:13337/" environment: - URLS_SELF_ISSUER=http://localhost:4444/ - URLS_CONSENT=http://localhost:5001/consent - URLS_LOGIN=http://localhost:5000/login - - URLS_LOGOUT=http://localhost:5000/logout - - URLS_POST_LOGOUT_REDIRECT=http://localhost:5000/ + - URLS_LOGOUT=http://localhost:5002/logout - DSN=memory - SECRETS_SYSTEM=youReallyNeedToChangeThis - OIDC_SUBJECT_TYPES_SUPPORTED=public,pairwise @@ -37,6 +36,14 @@ services: ports: - "5001:5001" restart: unless-stopped + logout: + build: logout_provider/ + environment: + - HYDRA_ADMIN_URL=http://hydra:4445 + - FLASK_ENV=development + ports: + - "5002:5002" + restart: unless-stopped login: build: login_provider/ environment: @@ -56,7 +63,7 @@ services: - DATABASE_NAME=postgres - DATABASE_HOST=psql ports: - - "5002:5000" + - "5003:5000" restart: unless-stopped psql: image: postgres:11 @@ -68,16 +75,11 @@ services: build: ./test/integration_tests environment: - BASE_URL=http://localhost:4444/ - - ACCESS_TOKEN_URL=http://hydra:4444/oauth2/token - - LOGOUT_URL=http://localhost:4444/oauth2/sessions/logout - - AUTHORIZE_URL=http://localhost:4444/oauth2/auth - - USERINFO_URL=http://hydra:4444/userinfo - KEY=testapp - SECRET=secret - - OAUTHLIB_INSECURE_TRANSPORT=true - FLASK_ENV=development # with this settings run: - ## `bash test/create-hydra-client.bash testapp clientsecret http://localhost:4445 http://127.0.0.1:13337/login/sso/authorized + ## `bash test/create-hydra-client.bash testapp clientsecret http://localhost:4445 http://127.0.0.1:13337/callback http://127.0.0.1:13337 http:/127.0.0.1:13337/logout ## to register a corresponding oauth client with hydra ports: - "13337:13337" diff --git a/logout_provider/Dockerfile b/logout_provider/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..c69128269214933445c04dae07300b4c71de0992 --- /dev/null +++ b/logout_provider/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.7-alpine + +RUN apk add gcc libc-dev libffi-dev + +WORKDIR /usr/src/app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ARG FLASK_PORT=5002 + +EXPOSE $FLASK_PORT + +ENV FLASK_ENV production +ENV FLASK_RUN_PORT $FLASK_PORT +ENV FLASK_RUN_HOST 0.0.0.0 +ENV HYDRA_ADMIN_URL http://localhost:4445 + + +CMD [ "flask", "run" ] diff --git a/logout_provider/app.py b/logout_provider/app.py new file mode 100644 index 0000000000000000000000000000000000000000..e17cebc77d81c2eba9278abcb26701b2e64cc0db --- /dev/null +++ b/logout_provider/app.py @@ -0,0 +1,44 @@ +from flask import abort, Flask, redirect, request +from flask.views import View +from hydra_client import HydraAdmin +import hydra_client +import logging +from os import environ + +HYDRA_ADMIN_URL = environ['HYDRA_ADMIN_URL'] +HYDRA = HydraAdmin(HYDRA_ADMIN_URL) + +app = Flask(__name__) +app.logger.setLevel(logging.INFO) + +@app.route('/logout', methods=['GET']) +def home(): + """Handles the OpenID Connect Logout flow + + Communicates with the hydra server to start the logout flow which uses backchannel and + frontchannel logout methods to log out the user from all applications they have + access to. + + Args: + logout_challenge: Reference to a logout challenge object in form of an alphanumeric + String. Can be used to retrieve the LogoutRequest object via the Hydra Admin API (GET) + + Returns: + Redirect to the url that is provided by the LogoutRequest object. + """ + challenge = request.args.get("logout_challenge") + app.logger.info("Logout request: challenge={0}".format(challenge)) + if not challenge: + abort(403) + try: + logout_request = HYDRA.logout_request(challenge) + except hydra_client.exceptions.NotFound: + app.logger.error("Not Found. Logout request not found. challenge={0}".format(challenge)) + abort(404) + except hydra_client.exceptions.HTTPError: + app.logger.error("Conflict. Logout request has been used already. challenge={0}".format(challenge)) + abort(503) + return redirect(logout_request.accept(subject=logout_request.subject)) + +if __name__ == '__main__': + app.run() diff --git a/logout_provider/requirements.txt b/logout_provider/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..435993d1d1d480f9f99bbac5ab7d32be2861099c --- /dev/null +++ b/logout_provider/requirements.txt @@ -0,0 +1,2 @@ +Flask +hydra-client diff --git a/test/create-hydra-client.bash b/test/create-hydra-client.bash index 8b4c78fd169d335a52483f820b53db9a1cff75ae..78017032e230e1e12d7c19c196294717a9f96ce5 100755 --- a/test/create-hydra-client.bash +++ b/test/create-hydra-client.bash @@ -9,9 +9,14 @@ KEY=$1 SECRET=$2 HOST_URL=$3 REDIRECT_URI=$4 +POST_LOGOUT_REDIRECT_URI=$5 +FRONTCHANNEL_LOGOUT_URI=$6 SCOPES="openid profile email openappstack_roles" +curl --request DELETE \ + $HOST_URL/clients/$KEY; + curl --header "Content-Type: application/json" \ --request POST \ - --data "{\"client_id\": \"$KEY\", \"client_name\": \"$KEY\", \"client_secret\": \"$SECRET\", \"redirect_uris\": [\"$REDIRECT_URI\"], \"scope\": \"$SCOPES\", \"grant_types\": [\"authorization_code\",\"refresh_token\"], \"response_types\": [\"code\"], \"token_endpoint_auth_method\": \"client_secret_basic\"}" \ + --data "{\"client_id\": \"$KEY\", \"client_name\": \"$KEY\", \"client_secret\": \"$SECRET\", \"frontchannel_logout_uri\": \"$FRONTCHANNEL_LOGOUT_URI\", \"post_logout_redirect_uris\": [\"$POST_LOGOUT_REDIRECT_URI\"], \"redirect_uris\": [\"$REDIRECT_URI\"], \"scope\": \"$SCOPES\", \"grant_types\": [\"authorization_code\",\"refresh_token\"], \"response_types\": [\"code\", \"token id_token\"], \"token_endpoint_auth_method\": \"client_secret_basic\"}" \ $HOST_URL/clients diff --git a/test/integration_tests/Dockerfile b/test/integration_tests/Dockerfile index 9dbbf62141109039333c9c3b80a3cb201477cc09..2692e305ec6fc2ff62325a8fb8a4811cfbc9ad25 100644 --- a/test/integration_tests/Dockerfile +++ b/test/integration_tests/Dockerfile @@ -3,13 +3,19 @@ FROM python:3.7-alpine WORKDIR /usr/src/app COPY requirements.txt ./ + +RUN apk --no-cache add \ + libffi-dev \ + python3-dev \ + libc-dev \ + openssl-dev \ + gcc + RUN pip3 install --no-cache-dir -r requirements.txt COPY . . ENV BASE_URL https://sso.oas.example.net -ENV ACCESS_TOKEN_URL https://sso.oas.example.net/oauth2/token -ENV AUTHORIZE_URL https://sso.oas.example.net/oauth2/auth ENV KEY testapp ENV SECRET verysecret diff --git a/test/integration_tests/README.md b/test/integration_tests/README.md index 431c297a5fd087df9382719fcf960d49ae8eeb78..83e20abeac0ded3b2351baeb6b3d90d6b82f670d 100644 --- a/test/integration_tests/README.md +++ b/test/integration_tests/README.md @@ -7,16 +7,27 @@ The single sign-on application needs to be up and running. Instructions on how t that can be found in `../../README.md`. ### Create oAuth Client -You also need to create an oAuth2-Client to enable this application to communicate with the +You also need to create an oAuth2-Client to enable this application to communicate with the oAuth server. To do that you can run the `create-hydra-client` script in this repository: ``` -bash ../create-hydra-client.bash testapplication clientsecret http://localhost:4445 http://localhost:13337/callback +bash ../create-hydra-client.bash testapplication clientsecret http://localhost:4445 http://localhost:13337/callback http://localhost:13337/ http://localhost:13337/logout ``` + `http://localhost:4445` refers to the hydra-admin service. `http://localhost:13337/callback` is the -callback address of the test application. Make sure it matches the address specified in `../../docker-compose.yml` -> `serve all --dangerous-force-http --dangerous-allow-insecure-redirect-urls "http://localhost:13337/callback":` +callback uri of the test application. The third uri (http://localhost:13337) will be used as a post +logout redirect uri. The agent is forwarded to this address after the single-sign-off process finishes. +The last argument specifies the uri that is used to trigger a +[OIDC frontchanel logout](https://openid.net/specs/openid-connect-frontchannel-1_0.html#ExampleFrontchannel). + + +Make sure that the callback url you specified as the 4th argument when executing the +`create-hydra-client.bash` script exactly matches one of the insecure +redirect urls that are specified in `../../docker-compose.yml` (line 16). The argument to look +out for is called `--dangerous-allow-insecure-redirect-urls`: + +> `serve all --dangerous-force-http --dangerous-allow-insecure-redirect-urls "http://localhost:13337/callback"` ### Create users @@ -47,13 +58,8 @@ if you run your setup locally. ``` export BASE_URL=http://localhost:4444/ # Hydra public API Base -export ACCESS_TOKEN_URL=http://localhost:4444/oauth2/token # Hydra token endpoint -export LOGOUT_URL=http://localhost:4444/oauth2/sessions/logout # Hydra logout endpoint -export AUTHORIZE_URL=http://localhost:4444/oauth2/auth # Hydra authentication endpoint -export USERINFO_URL=http://localhost:4444/userinfo # Hydra OpenID Connect userinfo endpoint export KEY=testapplication # name of your oauth/openID Connect client (application) export SECRET=clientsecret # secret of your oauth/openID Connect client (application) -export OAUTHLIB_INSECURE_TRANSPORT=true # allows connections via http ``` diff --git a/test/integration_tests/app.py b/test/integration_tests/app.py index 9fdbc42e85d0c67ec846cd1ae7bb1b9950fd0b8f..61577d95ee222a34341edd9a60b3be9115f99e09 100644 --- a/test/integration_tests/app.py +++ b/test/integration_tests/app.py @@ -1,70 +1,83 @@ -from flask import Flask, url_for, redirect, jsonify import json from os import environ -from flask_dance.consumer import OAuth2ConsumerBlueprint, oauth_authorized, oauth_error +from flask import Flask, url_for, redirect, jsonify, session, request +from oic.oic import Client +from oic.oic import RegistrationResponse +from oic.oic.message import AuthorizationResponse +from oic.utils.authn.client import CLIENT_AUTHN_METHOD +from oic import rndstr -BASE_URL=environ["BASE_URL"] -ACCESS_TOKEN_URL=environ["ACCESS_TOKEN_URL"] -AUTHORIZE_URL=environ["AUTHORIZE_URL"] -USERINFO_URL=environ["USERINFO_URL"] -KEY=environ["KEY"] -SECRET=environ["SECRET"] +BASE_URL = environ["BASE_URL"] +KEY = environ["KEY"] +SECRET = environ["SECRET"] app = Flask(__name__) app.secret_key = 'development' -sso = OAuth2ConsumerBlueprint( - "sso", __name__, - client_id=KEY, - client_secret=SECRET, - base_url=BASE_URL, - token_url=ACCESS_TOKEN_URL, - authorization_url=AUTHORIZE_URL, - ) +sso = Client(client_authn_method=CLIENT_AUTHN_METHOD) +sso.provider_config(BASE_URL) +sso_client_auth = {"client_id": KEY, "client_secret": SECRET} +sso_auth_args = { + "client_id": KEY, + "response_type": ["code"], + "scope": ["openid", "profile", "email", "openappstack_roles"], + "nonce": "", + "state": "", + "redirect_uri": "" + } -sso.error = {} - -app.register_blueprint(sso, url_prefix="/login") +sso_client_reg = RegistrationResponse(**sso_client_auth) +sso.store_registration_info(sso_client_reg) @app.route("/") def index(): - if sso.error: - errorpage = jsonify(sso.error) - sso.error = {} - return errorpage - elif not sso.token: - return redirect(url_for("sso.login")) - else: - return jsonify(sso.token) + if "state" not in session.keys(): + state = rndstr() + session["state"] = state + if session["state"] not in sso.grant.keys() or not sso.grant[session["state"]].is_valid(): + sso_auth_args["nonce"] = rndstr() + sso_auth_args["state"] = session["state"] + sso_auth_args["redirect_uri"] = url_for("callback", _external=True) + sso_auth_request = sso.construct_AuthorizationRequest(request_args=sso_auth_args) + return redirect(sso_auth_request.request(sso.authorization_endpoint)) + token = sso.grant[session["state"]].get_token() + return jsonify({"access_token": token.access_token}) @app.route("/userinfo") def info(): - if sso.authorized: - resp = sso.session.get(USERINFO_URL) - assert resp.ok - return jsonify(json.loads(resp.content.decode())) - else: - return redirect("/") + if "state" in session.keys() and sso.grant[session["state"]].is_valid: + token = sso.grant[session["state"]].get_token() + return jsonify(token.id_token.to_dict()) + return redirect("/") @app.route("/logout") def logout(): - if sso.authorized: - del sso.token - return redirect("/") - else: - return redirect("/") + del sso.grant[session["state"]] + del session["state"] + return redirect("/") -@oauth_authorized.connect -def save_token(blueprint, token): - # set OAuth token in the token storage backend - blueprint.token = token +@app.route("/single-sign-off") +def single_sign_off(): + redir = sso.construct_CheckSessionRequest(state=session["state"]) + answer = sso.do_end_session_request(state=session["state"], request_args={ + "id_token_hint": redir["id_token"], + "state": session["state"], + "post_logout_redirect_uri": url_for("index", _external=True) + }) + return redirect(answer.url) -@oauth_error.connect -def save_error(blueprint, **error): - # set error in the token storage backend - blueprint.error = error +@app.route("/callback") +def callback(): + if "error" in request.args: + return jsonify(request.args) + aresp = sso.parse_response(AuthorizationResponse, info=json.dumps(request.args)) + assert aresp["state"] == session["state"] + token = sso.do_access_token_request( + state=aresp["state"], + request_args={"code": aresp["code"]}) + sso.store_response(session["state"], token) + return redirect(url_for("index")) if __name__ == "__main__": app.run() - diff --git a/test/integration_tests/requirements.txt b/test/integration_tests/requirements.txt index 40df930d93c0823aea07df942a1b80231bf38ace..2d579111be56d50870a2d3723b11a42ab1e22c58 100644 --- a/test/integration_tests/requirements.txt +++ b/test/integration_tests/requirements.txt @@ -1,3 +1,3 @@ Flask -Flask-Dance -blinker +oic +Flask-Session diff --git a/test/integration_tests/test/behave/features/environment.py b/test/integration_tests/test/behave/features/environment.py index fe5e4cb26edb3b4aaa73ae8d328fa6878b6c7ac8..dddff08475705b1e9ff63f42c2cada2e848ca92c 100644 --- a/test/integration_tests/test/behave/features/environment.py +++ b/test/integration_tests/test/behave/features/environment.py @@ -40,6 +40,7 @@ def before_tag(context, tag): values['home'] = userdata.get('url') values['logout'] = values['home'] + "/logout" values['userinfo'] = values['home'] + "/userinfo" + values['single-sign-off'] = values['home'] + "/single-sign-off" values['username'] = userdata.get('username') values['username2'] = userdata.get('username2') values['password'] = userdata.get('password') diff --git a/test/integration_tests/test/behave/features/logout.feature b/test/integration_tests/test/behave/features/logout.feature new file mode 100644 index 0000000000000000000000000000000000000000..6df862eebbd57eabee0a95b48fc1878e08bebb27 --- /dev/null +++ b/test/integration_tests/test/behave/features/logout.feature @@ -0,0 +1,20 @@ +@oauth +Feature: Test logout-provider function + As an OAS user + I want to be able to use single-sign off triggered by an + OAS APP. And verify that even though I selected remember me + my session was removed. + +Scenario: Login with a valid user and remember session + Given the oauth client "home" URL was opened + And the element "input#remember" is visible + When I enter the "username" in the inputfield "input#username" + And I enter the "password" in the inputfield "input#password" + And I click on the element "input#remember" + And I click on the button "input#submit" + Then I wait on element "input#password" for 1000ms to not exist + +Scenario: Logout using the single sign-off feature + Given the oauth client "single-sign-off" URL was opened + And I pause for 1000ms + Then I wait on element "input#username" for 1000ms to exist diff --git a/test/integration_tests/test/behave/features/reject_unauthorized_logins.feature b/test/integration_tests/test/behave/features/reject_unauthorized_logins.feature index e4b6150bc599cd68e37ad2c57cd1386b61893c93..fb1c0639fa48165707bfce35c402717a6d26d4a8 100644 --- a/test/integration_tests/test/behave/features/reject_unauthorized_logins.feature +++ b/test/integration_tests/test/behave/features/reject_unauthorized_logins.feature @@ -12,7 +12,6 @@ Scenario: Login with a valid user without access to an application And I click on the button "input#submit" Then I wait on element "input#password" for 1000ms to not exist And I expect that element "input#username" does not exist - And I expect that the path is "/" And I expect that element "body" contains the text "error" And I expect that element "body" contains the text "Permission denied" And I expect that element "body" contains the text "missing application permission" @@ -25,7 +24,6 @@ Scenario: Login with an invalid user And I click on the button "input#submit" Then I wait on element "input#password" for 1000ms to not exist And I expect that element "input#username" does not exist - And I expect that the path is "/" And I expect that element "body" contains the text "error" And I expect that element "body" contains the text "Login denied" And I expect that element "body" contains the text "Invalid username or password"