feat: sso login flow

This commit is contained in:
Livio Bieri 2023-11-28 11:33:57 +01:00 committed by Christian Cueni
parent 6f90d381f3
commit a282427f24
12 changed files with 192 additions and 242 deletions

View File

@ -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/"

View File

@ -274,7 +274,9 @@ onMounted(() => {
</PopoverPanel>
</Popover>
</div>
<div v-else><a class="" href="/login">Login</a></div>
<div v-else>
<a class="" :href="`/sso/login?lang=${userStore.language}`">Login</a>
</div>
</div>
</div>
</div>

View File

@ -91,7 +91,7 @@ const userStore = useUserStore();
{{ $t("login.ssoText") }}
</p>
<p class="btn-primary mt-8">
<a :href="`/sso/login/?lang=${userStore.language}`">
<a :href="`/sso/login?lang=${userStore.language}`">
{{ $t("login.ssoLogin") }}
</a>
</p>
@ -100,6 +100,15 @@ const userStore = useUserStore();
{{ $t("login.demoLogin") }}
</a>
</p>
<p class="mt-8">
<a href="/sso/signup">Test SSO Signup (TODO)</a>
</p>
<p class="mt-8">
<a href="/sso/signup?course=vv">Test SSO Signup VV (TODO)</a>
</p>
<p class="mt-8">
<a href="/sso/signup?course=uk">Test SSO Signup UK (TODO)</a>
</p>
</div>
</div>
<div class="container-medium">

View File

@ -1,5 +1,14 @@
<script setup lang="ts">
import WizardPage from "@/components/onboarding/WizardPage.vue";
import { useUserStore } from "@/stores/user";
const props = defineProps({
courseType: {
type: String,
required: true,
},
});
const user = useUserStore();
</script>
<template>
@ -7,10 +16,15 @@ import WizardPage from "@/components/onboarding/WizardPage.vue";
<template #content>
<h2 class="my-10">Konto erstellen</h2>
<p class="mb-4">Damit du myVBV nutzen kannst, brauchst du ein Konto.</p>
<a href="#" class="btn-primary">Konto erstellen</a>
<a
:href="`/sso/signup?course=${props.courseType}&lang=${user.language}`"
class="btn-primary"
>
Konto erstellen
</a>
<p class="mb-4 mt-12">Hast du schon ein Konto?</p>
<a href="/sso/login/" class="btn-secondary">Anmelden</a>
<a :href="`/sso/login?lang=${user.language}`" class="btn-secondary">Anmelden</a>
</template>
</WizardPage>
</template>

View File

@ -270,6 +270,7 @@ const router = createRouter({
path: "account/create",
component: () => import("@/pages/onboarding/AccountSetup.vue"),
name: "accountCreate",
props: true,
},
{
path: "account/confirm",

View File

@ -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",

View File

@ -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()

View File

@ -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,

View File

@ -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 = """
<html>
<head>
<script src="https://pay.sandbox.datatrans.com/upp/payment/js/datatrans-2.0.0.js"></script>
</head>
<body>
<button id="payButton">Pay Now</button>
<script>
var payButton = document.getElementById('payButton');
payButton.onclick = function() {
Datatrans.startPayment({
transactionId: "{{ transaction_id }}",
opened: function() { console.log('payment-form opened'); },
loaded: function() { console.log('payment-form loaded'); },
closed: function() { console.log('payment-page closed'); },
error: function(errorData) { console.log('error', errorData); }
});
};
</script>
</body>
</html>
"""
- 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 = "<html><body><h1>Payment Success</h1></body></html>"
ERROR_PAGE = "<html><body><h1>Payment Error</h1></body></html>"
CANCEL_PAGE = "<html><body><h1>Payment Cancelled</h1></body></html>"
### 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)
```

View File

@ -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")

View File

@ -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",
),
]

View File

@ -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("/")