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(() => {
-
+
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") }}
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 @@
@@ -7,10 +16,15 @@ import WizardPage from "@/components/onboarding/WizardPage.vue";
Konto erstellen
Damit du myVBV nutzen kannst, brauchst du ein Konto.
- Konto erstellen
+
+ Konto erstellen
+
Hast du schon ein Konto?
- Anmelden
+ Anmelden
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("/")