From a282427f2492a1b92a487d3767bd2fcb5d1e1ac7 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Tue, 28 Nov 2023 11:33:57 +0100 Subject: [PATCH] feat: sso login flow --- caprover_deploy.sh | 2 +- .../components/header/MainNavigationBar.vue | 4 +- client/src/pages/LoginPage.vue | 11 +- client/src/pages/onboarding/AccountSetup.vue | 18 +- client/src/router/index.ts | 1 + server/config/settings/base.py | 72 +++--- server/vbv_lernwelt/course_session/utils.py | 19 ++ server/vbv_lernwelt/importer/services.py | 2 +- server/vbv_lernwelt/shop/README.md | 216 +++++------------- server/vbv_lernwelt/sso/client.py | 14 +- server/vbv_lernwelt/sso/urls.py | 5 +- server/vbv_lernwelt/sso/views.py | 70 +++--- 12 files changed, 192 insertions(+), 242 deletions(-) create mode 100644 server/vbv_lernwelt/course_session/utils.py diff --git a/caprover_deploy.sh b/caprover_deploy.sh index efb90f50..a92d63e2 100755 --- a/caprover_deploy.sh +++ b/caprover_deploy.sh @@ -29,7 +29,7 @@ APP_NAME=${1:-$(generate_default_app_name)} export VITE_APP_ENVIRONMENT="dev-$APP_NAME" if [[ "$APP_NAME" == "myvbv-stage" ]]; then - export VITE_OAUTH_API_BASE_URL="https://vbvtst.b2clogin.com/vbvtst.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/" + export VITE_OAUTH_API_BASE_URL="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/" export VITE_APP_ENVIRONMENT="stage-caprover" elif [[ "$APP_NAME" == prod* ]]; then export VITE_OAUTH_API_BASE_URL="https://edumgr.b2clogin.com/edumgr.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/" diff --git a/client/src/components/header/MainNavigationBar.vue b/client/src/components/header/MainNavigationBar.vue index 836f8f40..8a5c6c7e 100644 --- a/client/src/components/header/MainNavigationBar.vue +++ b/client/src/components/header/MainNavigationBar.vue @@ -274,7 +274,9 @@ onMounted(() => { -
Login
+
+ Login +
diff --git a/client/src/pages/LoginPage.vue b/client/src/pages/LoginPage.vue index 0997e487..ac3d39cd 100644 --- a/client/src/pages/LoginPage.vue +++ b/client/src/pages/LoginPage.vue @@ -91,7 +91,7 @@ const userStore = useUserStore(); {{ $t("login.ssoText") }}

- + {{ $t("login.ssoLogin") }}

@@ -100,6 +100,15 @@ const userStore = useUserStore(); {{ $t("login.demoLogin") }}

+

+ Test SSO Signup (TODO) +

+

+ Test SSO Signup VV (TODO) +

+

+ Test SSO Signup UK (TODO) +

diff --git a/client/src/pages/onboarding/AccountSetup.vue b/client/src/pages/onboarding/AccountSetup.vue index 9ec4f170..c48ef3c6 100644 --- a/client/src/pages/onboarding/AccountSetup.vue +++ b/client/src/pages/onboarding/AccountSetup.vue @@ -1,5 +1,14 @@ diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 58712b3a..4e914e66 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -270,6 +270,7 @@ const router = createRouter({ path: "account/create", component: () => import("@/pages/onboarding/AccountSetup.vue"), name: "accountCreate", + props: true, }, { path: "account/confirm", diff --git a/server/config/settings/base.py b/server/config/settings/base.py index 36740741..9ea12edb 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -558,7 +558,14 @@ else: # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = env.list( - "IT_DJANGO_ALLOWED_HOSTS", default=["localhost", "0.0.0.0", "127.0.0.1"] + "IT_DJANGO_ALLOWED_HOSTS", + # FIXME @livioso: Remove ngrok -> Datatrans Testing (hope I don't commit this) + default=[ + "localhost", + "0.0.0.0", + "127.0.0.1", + "e124-2a02-21b4-9679-d800-9c5-c205-e72c-82f2.ngrok-free.app", + ], ) # CACHES @@ -584,38 +591,45 @@ if "django_redis.cache.RedisCache" in env("IT_DJANGO_CACHE_BACKEND", default="") }, } -# OAuth/OpenId Connect -IT_OAUTH_TENANT_ID = env.str("IT_OAUTH_TENANT_ID", default=None) +# OAuth (SSO) settings +OAUTH_SIGNUP_TENANT_ID = env("OAUTH_SIGNUP_TENANT_ID", default=None) +OAUTH_SIGNUP_PARAMS = ( + {"tenant_id": OAUTH_SIGNUP_TENANT_ID} if OAUTH_SIGNUP_TENANT_ID else {} +) -if IT_OAUTH_TENANT_ID: - IT_OAUTH_AUTHORIZE_PARAMS = {"tenant_id": IT_OAUTH_TENANT_ID} -else: - IT_OAUTH_AUTHORIZE_PARAMS = {} - -OAUTH = { - "client_name": env("IT_OAUTH_CLIENT_NAME", default="lernetz"), - "client_id": env("IT_OAUTH_CLIENT_ID", default="iterativ"), - "client_secret": env("IT_OAUTH_CLIENT_SECRET", default=""), - "authorize_params": IT_OAUTH_AUTHORIZE_PARAMS, - "access_token_params": IT_OAUTH_AUTHORIZE_PARAMS, - "api_base_url": env( - "IT_OAUTH_API_BASE_URL", - default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/", - ), - "local_redirect_uri": env( - "IT_OAUTH_LOCAL_REDIRECT_URI", default="http://localhost:8000/sso/callback/" - ), - "server_metadata_url": env( - "IT_OAUTH_SERVER_METADATA_URL", - default="https://sso.test.b.lernetz.host/auth/realms/vbv/.well-known/openid-configuration", - ), - "client_kwargs": { - "scope": env("IT_OAUTH_SCOPE", default="openid email"), - "token_endpoint_auth_method": "client_secret_post", - "token_placement": "body", +AUTHLIB_OAUTH_CLIENTS = { + "signup": { + # azure + "client_id": env("OAUTH_SIGNUP_CLIENT_ID", ""), + "client_secret": env("OAUTH_SIGNUP_CLIENT_SECRET", ""), + "server_metadata_url": env("OAUTH_SIGNUP_SERVER_METADATA_URL", ""), + "access_token_params": OAUTH_SIGNUP_PARAMS, + "authorize_params": OAUTH_SIGNUP_PARAMS, + "client_kwargs": { + "scope": "openid", + "token_endpoint_auth_method": "client_secret_post", + "token_placement": "body", + }, + }, + "signin": { + # keycloak + "client_id": env("OAUTH_SIGNIN_CLIENT_ID", ""), + "client_secret": env("OAUTH_SIGNIN_CLIENT_SECRET", ""), + "server_metadata_url": env("OAUTH_SIGNIN_SERVER_METADATA_URL", ""), + "client_kwargs": { + "scope": "openid email profile", + }, }, } +OAUTH_SIGNUP_REDIRECT_URI = env( + "OAUTH_SIGNUP_REDIRECT_URI", default="http://localhost:8000/sso/login" +) + +OAUTH_SIGNIN_REDIRECT_URI = env( + "OAUTH_SIGNIN_REDIRECT_URI", default="http://localhost:8000/sso/callback" +) + GRAPHENE = { "SCHEMA": "vbv_lernwelt.core.schema.schema", "SCHEMA_OUTPUT": "../client/src/gql/schema.graphql", diff --git a/server/vbv_lernwelt/course_session/utils.py b/server/vbv_lernwelt/course_session/utils.py new file mode 100644 index 00000000..b37b1831 --- /dev/null +++ b/server/vbv_lernwelt/course_session/utils.py @@ -0,0 +1,19 @@ +from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.consts import ( + COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, +) +from vbv_lernwelt.course.models import CourseSession + + +def has_course_session_user_vv(user: User) -> bool: + vv_course_ids = [ + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, + ] + + return CourseSession.objects.filter( + course__id__in=vv_course_ids, coursesessionuser__user=user + ).exists() diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index be64194a..e62f1a04 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -216,7 +216,7 @@ def create_or_update_user( sso_id: str = None, contract_number: str = "", date_of_birth: str = "", -): +) -> User: logger.debug( "create_or_update_user", email=email, diff --git a/server/vbv_lernwelt/shop/README.md b/server/vbv_lernwelt/shop/README.md index 48efefbc..65e39c9d 100644 --- a/server/vbv_lernwelt/shop/README.md +++ b/server/vbv_lernwelt/shop/README.md @@ -1,188 +1,74 @@ -# Datatrans - Proof of Concept +# Setup -## Setup manual steps +## Shop Product -- `HMAC_KEY`: https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp -- `BASIC_AUTH`: `echo -n "{merchantid}:{password}" | base64` (`merchantid`: `11xxxxxx`): https://admin.sandbox.datatrans.com/MenuDispatch.jsp?main=1&sub=4 +- In Django Shop App, create a new product (Products model). +- `SKU` must be `VV`, Price 30000 (300_00 -> 300.00 CHF), name & description can be anything. +- Done for staging but not yet for production! -## Links +## Datatrans -- https://admin.sandbox.datatrans.com -- https://api-reference.datatrans.ch/#section/Idempotency -- https://docs.datatrans.ch/docs/redirect-lightbox#section-initializing-transactions +- Set `DATATRANS_BASIC_AUTH_KEY`: + - https://admin.sandbox.datatrans.com/MenuDispatch.jsp?main=1&sub=4 + - `echo -n "{merchantid}:{password}" | base64` -## Code +- Set `DATATRANS_HMAC_KEY`: + - https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp -Simple example of the payment flow with Datatrans: +- Ensure that the webhook is set up correctly by Datatrans: + - Be default transitions from `initialized` to `failed` do not trigger the webhook. + - Edgecase: When user starts a datatrans payment and then closes the browser, the payment will be in `initialized` + state forever. -> That's why we need the webhook for `initialized` -> `failed` transitions. + - This can and needs to be enabled by datatrans (according to Mario from datatrans). + - Livio 21.11.23: Mario promised to enable it, + - Livio 27.11.23. Not yet enabled for the sandbox. -> Followed up! + - Livio: TODO still not enabled. Follow up again! -```python +### Production / "going live" -from flask import Flask, request, render_template_string, jsonify, abort -import uuid -import hmac -import hashlib -import requests -import os +For Production: We use the proper production datatrans endpoint! -app = Flask(__name__) +1. Coordinate with datatrans to get production account. +2. Set `DATATRANS_BASIC_AUTH_KEY` and `DATATRANS_HMAC_KEY` to the production values (see above). +3. Ensure that the webhook is set up correctly by Datatrans (see above). -if "HMAC_KEY" not in os.environ: - exit("Please set the HMAC_KEY environment variable.") +## OAUTH -if "BASIC_AUTH" not in os.environ: - exit("Please set the BASIC_AUTH environment variable.") +Make sure that the following env vars are set: -# https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp -HMAC_KEY = os.environ["HMAC_KEY"] -BASIC_AUTH = os.environ["BASIC_AUTH"] -API_ENDPOINT = "https://api.sandbox.datatrans.com/v1/transactions" +### Azure B2C -LIGHTBOX_PAGE = """ - - - - - - - - - -""" +- Set `OAUTH_SIGNUP_CLIENT_ID` +- Set `OAUTH_SIGNUP_CLIENT_SECRET` +- Set `OAUTH_SIGNUP_SERVER_METADATA_URL` (.well-known/openid-configuration) +- Set `OAUTH_SIGNUP_TENANT_ID` -SUCCESS_PAGE = "

Payment Success

" -ERROR_PAGE = "

Payment Error

" -CANCEL_PAGE = "

Payment Cancelled

" +### Keycloak -# TODO: There is now way to test this locally, so we need to use ngrok (?) -BASE_URL = "https://89d3-2a02-21b4-9679-d800-ac5f-489-e9f6-694e.ngrok-free.app" +- Set `OAUTH_SIGNIN_CLIENT_ID` +- Set `OAUTH_SIGNIN_CLIENT_SECRET` +- Set `OAUTH_SIGNIN_SERVER_METADATA_URL` (.well-known/openid-configuration) +### Redirect URIs -@app.route("/success", methods=["GET"]) -def success(): - return render_template_string(SUCCESS_PAGE) +- Set `OAUTH_SIGNUP_REDIRECT_URI` (`.../sso/login` e.g. `https://myvbv-stage.iterativ.ch/sso/login`) +- Set `OAUTH_SIGNIN_REDIRECT_URI` (`.../sso/callback` e.g. `https://myvbv-stage.iterativ.ch/sso/callback`) +### Frontend: -@app.route("/error", methods=["GET"]) -def error(): - return render_template_string(ERROR_PAGE) +- Update `VITE_OAUTH_API_BASE_URL` in `caprover_deploy.sh` for production. + - NEEDS to be updated! Should be the SSO Prod one from Lernnetz -> Lookup from Metadata URL +### Cleanup -@app.route("/cancel", methods=["GET"]) -def cancel(): - return render_template_string(CANCEL_PAGE) +After everything runs fine, we should be able to remove the following env vars: +1. `IT_OAUTH_TENANT_ID` +2. `IT_OAUTH_CLIENT_NAME` +3. `IT_OAUTH_CLIENT_ID` +4. `IT_OAUTH_CLIENT_SECRET` +5. `IT_OAUTH_API_BASE_URL` +6. `IT_OAUTH_LOCAL_REDIRECT_URI` +7. `IT_OAUTH_SERVER_METADATA_URL` +8. `IT_OAUTH_SCOPE` -@app.route("/init_transaction", methods=["GET"]) -def init_transaction(): - # TODO - # for debugging, it might be handy to know the - # user who initiated the transaction - refno = uuid.uuid4().hex - - # TODO - # The language of user - language = "en" - - # Transaction payload - payload = { - "currency": "CHF", - "refno": refno, - "amount": 10_00, # 10 CHF - "autoSettle": True, - "language": language, - "redirect": { - "successUrl": f"{BASE_URL}/success", - "errorUrl": f"{BASE_URL}/error", - "cancelUrl": f"{BASE_URL}/cancel", - }, - "webhook": { - "url": f"{BASE_URL}/webhook", - }, - } - - # Headers - headers = { - "Authorization": f"Basic {BASIC_AUTH}", - "Content-Type": "application/json", - } - - # 1. USING LIGHTBOX - response = requests.post(API_ENDPOINT, json=payload, headers=headers) - - if response.ok: - transaction_id = response.json().get("transactionId") - return render_template_string(LIGHTBOX_PAGE, transaction_id=transaction_id) - else: - return ( - jsonify( - {"error": "Failed to initiate transaction", "details": response.text} - ), - response.status_code, - ) - - # 2. USING REDIRECT - # # Send POST request to Datatrans API - # response = requests.post(url, json=payload, headers=headers) - - # if response.ok: - # transaction_id = response.json().get('transactionId') - # payment_url = f'https://pay.sandbox.datatrans.com/v1/start/{transaction_id}' - # return redirect(payment_url) - # else: - # # Return error message - # return jsonify({"error": "Failed to initiate transaction", "details": response.text}), response.status_code - - -@app.route("/webhook", methods=["POST"]) -def webhook(): - """ - Checks the Datatrans-Signature header of the incoming request and validates the signature: - https://api-reference.datatrans.ch/#section/Webhook/Webhook-signing - """ - - # TODO Check the state here too! - - hmac_key = HMAC_KEY - - def calculate_signature(key: str, timestamp: str, payload: str) -> str: - key_bytes = bytes.fromhex(key) - signing_data = f"{timestamp}{payload}".encode("utf-8") - hmac_obj = hmac.new(key_bytes, signing_data, hashlib.sha256) - return hmac_obj.hexdigest() - - # Header format: - # Datatrans-Signature: t={{timestamp}},s0={{signature}} - datatrans_signature = request.headers.get("Datatrans-Signature", "") - - try: - parts = datatrans_signature.split(",") - timestamp = parts[0].split("=")[1] - received_signature = parts[1].split("=")[1] - - calculated_signature = calculate_signature( - hmac_key, timestamp, request.data.decode("utf-8") - ) - - if calculated_signature == received_signature: - return "Signature validated.", 200 - else: - abort(400, "Invalid signature.") - except (IndexError, ValueError): - abort(400, "Invalid Datatrans-Signature header.") - - -if __name__ == "__main__": - app.run(debug=True, host="0.0.0.0", port=5500) - -``` diff --git a/server/vbv_lernwelt/sso/client.py b/server/vbv_lernwelt/sso/client.py index 875bfec1..ea10b6bf 100644 --- a/server/vbv_lernwelt/sso/client.py +++ b/server/vbv_lernwelt/sso/client.py @@ -1,15 +1,5 @@ from authlib.integrations.django_client import OAuth -from django.conf import settings -# # https://docs.authlib.org/en/latest/client/frameworks.html#frameworks-clients oauth = OAuth() -oauth.register( - name=settings.OAUTH["client_name"], - client_id=settings.OAUTH["client_id"], - client_secret=settings.OAUTH["client_secret"], - request_token_url=None, - request_token_params=None, - authorize_params=settings.OAUTH["authorize_params"], - client_kwargs=settings.OAUTH["client_kwargs"], - server_metadata_url=settings.OAUTH["server_metadata_url"], -) +oauth.register(name="signup") +oauth.register(name="signin") diff --git a/server/vbv_lernwelt/sso/urls.py b/server/vbv_lernwelt/sso/urls.py index d3bea976..18abdbf2 100644 --- a/server/vbv_lernwelt/sso/urls.py +++ b/server/vbv_lernwelt/sso/urls.py @@ -6,10 +6,11 @@ from . import views app_name = "sso" urlpatterns = [ - path(r"login/", django_view_authentication_exempt(views.login), name="login"), + path(r"login/", django_view_authentication_exempt(views.signin), name="login"), + path(r"signup/", django_view_authentication_exempt(views.signup), name="signup"), path( r"callback/", - django_view_authentication_exempt(views.authorize), + django_view_authentication_exempt(views.authorize_signin), name="authorize", ), ] diff --git a/server/vbv_lernwelt/sso/views.py b/server/vbv_lernwelt/sso/views.py index 957a1b8e..adb47d82 100644 --- a/server/vbv_lernwelt/sso/views.py +++ b/server/vbv_lernwelt/sso/views.py @@ -5,6 +5,8 @@ from django.contrib.auth import login as dj_login from django.shortcuts import redirect from sentry_sdk import capture_exception +from vbv_lernwelt.course.models import CourseSession +from vbv_lernwelt.course_session.utils import has_course_session_user_vv from vbv_lernwelt.importer.services import create_or_update_user from vbv_lernwelt.sso.client import oauth from vbv_lernwelt.sso.jwt import decode_jwt @@ -14,44 +16,56 @@ logger = structlog.get_logger(__name__) OAUTH_FAIL_REDIRECT = "login-error" -def login(request): - oauth_client = oauth.create_client(settings.OAUTH["client_name"]) - redirect_uri = settings.OAUTH["local_redirect_uri"] - language = request.GET.get("lang", "de") - return oauth_client.authorize_redirect(request, redirect_uri, lang=language) +def signup(request): + course = request.GET.get("course") + redirect_uri = settings.OAUTH_SIGNUP_REDIRECT_URI + logger.debug(f"SSO Signup (course={course})", sso_signup_redirect_uri=redirect_uri) + return oauth.signup.authorize_redirect(request, redirect_uri, state=course) -def authorize(request): +def signin(request): + # course query OR state when coming from signup (oauth) + course = request.GET.get("course", request.GET.get("state")) + redirect_uri = settings.OAUTH_SIGNIN_REDIRECT_URI + logger.info(f"SSO Login (course={course})", sso_login_redirect_uri=redirect_uri) + return oauth.signin.authorize_redirect( + request, redirect_uri, state=course, lang=request.GET.get("lang", "de") + ) + + +def authorize_signin(request): try: - logger.debug(request, label="sso") - token = getattr(oauth, settings.OAUTH["client_name"]).authorize_access_token( - request - ) - decoded_token = decode_jwt(token["id_token"]) - # logger.debug(label="sso", decoded_token=decoded_token) + jwt_token = oauth.signin.authorize_access_token(request) except OAuthError as e: logger.error(e, exc_info=True, label="sso") if not settings.DEBUG: capture_exception(e) - return redirect(f"/{OAUTH_FAIL_REDIRECT}?state=someerror") # to be defined + return redirect(f"/{OAUTH_FAIL_REDIRECT}?state=oautherror") + + id_token = decode_jwt(jwt_token["id_token"]) + course = request.GET.get("state") + + logger.debug( + f"SSO Authorize (course={course})", + sso_authorize_id_token=id_token, + ) - user_data = _user_data_from_token_data(decoded_token) user = create_or_update_user( - email=user_data.get("email").lower(), - sso_id=user_data.get("sso_id"), - first_name=user_data.get("first_name", ""), - last_name=user_data.get("last_name", ""), + email=id_token.get("email", ""), + sso_id=id_token.get("oid"), + first_name=id_token.get("given_name", ""), + last_name=id_token.get("family_name", ""), ) dj_login(request, user) - return redirect(f"/") - -def _user_data_from_token_data(token: dict) -> dict: - first_email = token.get("emails", [""])[0] - return { - "first_name": token.get("given_name", ""), - "last_name": token.get("family_name", ""), - "email": first_email, - "sso_id": token.get("oid"), - } + # figure out where to redirect to (onboarding or home) + if course == "vv" and not has_course_session_user_vv(user): + return redirect("/onboarding/vv/account/create") + elif ( + course == "uk" + and not CourseSession.objects.filter(coursesessionuser__user=user).exists() + ): + return redirect("/onboarding/uk/account/create") + else: + return redirect("/")