Skip to content

Commit

Permalink
WIP for #1692
Browse files Browse the repository at this point in the history
  • Loading branch information
oharsta committed Nov 18, 2024
1 parent 5c165cf commit 3610e7f
Show file tree
Hide file tree
Showing 11 changed files with 228 additions and 36 deletions.
17 changes: 16 additions & 1 deletion client/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ export function me(config) {
}
}

// Mock
export function ebInterruptData() {
return fetchJson("/api/mock/interrupt_data")
}

// Mock
export function ebStopInterruptFlow() {
return fetchDelete("/api/mock/stop_interrupt_flow")
}

export function refreshUser() {
return fetchJson("/api/users/refresh");
}
Expand Down Expand Up @@ -967,7 +977,12 @@ export function plscSync() {
return fetchJson("/api/plsc/syncing");
}

export function proxyAuthz(userUid, serviceEntityId, idpEntityId) {
export function proxyAuthzEngineBlock(userUid, serviceEntityId, idpEntityId) {
const body = {user_id: userUid.trim(), service_id: serviceEntityId.trim(), issuer_id: idpEntityId.trim()};
return postPutJson("/api/users/proxy_authz_eb", body, "post", false);
}

export function proxyAuthzEduTeams(userUid, serviceEntityId, idpEntityId) {
const body = {user_id: userUid.trim(), service_id: serviceEntityId.trim(), issuer_id: idpEntityId.trim()};
return postPutJson("/api/users/proxy_authz", body, "post", false);
}
Expand Down
6 changes: 6 additions & 0 deletions client/src/locale/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -2124,6 +2124,12 @@ const en = {
userUid: "User UID",
serviceEntityId: "Service entity ID",
useSRAMServiceEntityId: "Use SRAM service entity ID",
mimic: "Choose integration backend",
mimicTooltip: "The integration backend is either eduTeams or EB.<br><br> The main difference is that EB will POST " +
"to the server <code>interrupt</code> endpoint and implicitely login the user. <br><br>EduTeams will redirect to the " +
"client <code>interrupt</code> page which will trigger an explicit authentication login.",
eduTeams: "EduTeams",
engineBlock: "SURFconext / EngineBlock",
idpEntityId: "IdP entity ID",
start: "Submit",
reset: "Reset",
Expand Down
24 changes: 22 additions & 2 deletions client/src/pages/ProxyLogin.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,33 @@ import React, {useState} from "react";
import "./ProxyLogin.scss";
import {Loader} from "@surfnet/sds";
import I18n from "../locale/I18n";
import {proxyAuthz} from "../api";
import {proxyAuthzEduTeams, proxyAuthzEngineBlock} from "../api";
import InputField from "../components/InputField";
import Button from "../components/Button";
import {isEmpty} from "../utils/Utils";
import CheckBox from "../components/CheckBox";
import SelectField from "../components/SelectField";

export default function ProxyLogin({config}) {

const integrationBackendOptions = [
I18n.t("system.proxy.eduTeams"),
I18n.t("system.proxy.engineBlock")
].map(backend => ({label: backend, value: backend}));

const [userUid, setUserUid] = useState(" ");
const [serviceEntityId, setServiceEntityId] = useState(" ");
const [idpEntityId, setIdpEntityId] = useState("");
const [useSRAMServiceEntityId, setUseSRAMServiceEntityId] = useState(false);
const [integrationBackend, setIntegrationBackend] = useState(integrationBackendOptions[0]);
const [proxyAuthzResult, setProxyAuthzResult] = useState(null);
const [proxyAuthzError, setProxyAuthzError] = useState(null);
const [loading, setLoading] = useState(false);

const doProxyAuthz = () => {
setLoading(true);
proxyAuthz(userUid, serviceEntityId, idpEntityId)
const promise = integrationBackend.value === I18n.t("system.proxy.engineBlock") ? proxyAuthzEngineBlock : proxyAuthzEduTeams;
promise(userUid, serviceEntityId, idpEntityId)
.then(res => {
setProxyAuthzResult(res);
setLoading(false);
Expand Down Expand Up @@ -61,21 +69,33 @@ export default function ProxyLogin({config}) {
onChange={e => setUserUid(e.target.value)}
name={I18n.t("system.proxy.userUid")}
required={true}/>

<InputField value={serviceEntityId}
onChange={e => {
setServiceEntityId(e.target.value)
setUseSRAMServiceEntityId(false);
}}
name={I18n.t("system.proxy.serviceEntityId")}
required={true}/>

<CheckBox name="userSRAM"
value={useSRAMServiceEntityId}
info={I18n.t("system.proxy.useSRAMServiceEntityId")}
onChange={toggleUseSRAMServiceEntityId}/>

<SelectField value={integrationBackend}
options={integrationBackendOptions}
name={I18n.t("system.proxy.mimic")}
toolTip={I18n.t("system.proxy.mimicTooltip")}
required={true}
searchable={false}
onChange={val => setIntegrationBackend(val)}/>

<InputField value={idpEntityId}
onChange={e => setIdpEntityId(e.target.value)}
name={I18n.t("system.proxy.idpEntityId")}
required={true}/>

<div className="actions">
<Button txt={I18n.t("system.proxy.reset")}
onClick={resetForm}
Expand Down
49 changes: 43 additions & 6 deletions server/api/mock_user.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import os
import uuid

from flask import Blueprint
import base64
from flask import Blueprint, current_app
from flask import request as current_request, session
from werkzeug.exceptions import Forbidden

from server.api.base import json_endpoint
from signxml import XMLSigner, XMLVerifier
import xml.etree.ElementTree as ET
from lxml import etree
from server.api.base import json_endpoint, query_param
from server.auth.secrets import generate_token
from server.auth.security import is_admin_user, CSRF_TOKEN
from server.auth.security import is_admin_user, CSRF_TOKEN, confirm_allow_impersonation, confirm_write_access
from server.auth.surf_conext import surf_public_signing_certificate
from server.auth.user_claims import add_user_claims
from server.db.db import db
from server.db.domain import User
from server.tools import read_file

mock_user_api = Blueprint("mock_user_api", __name__, url_prefix="/api/mock")

Expand All @@ -20,7 +24,8 @@
def login_user():
if not os.environ.get("ALLOW_MOCK_USER_API", None):
raise Forbidden()

if session.get("eb_interrupt_flow", False):
return None, 201
data = current_request.get_json()
sub = data["sub"] # oidc sub maps to sbs uid - see user_claims
user = User.query.filter(User.uid == sub).first() or User(created_by="system", updated_by="system",
Expand All @@ -44,3 +49,35 @@ def login_user():
if CSRF_TOKEN not in session:
session[CSRF_TOKEN] = generate_token()
return None, 201


@mock_user_api.route("/interrupt_data", methods=["GET"], strict_slashes=False)
@json_endpoint
def eb_interrupt_data():
if not os.environ.get("ALLOW_MOCK_USER_API", None):
raise Forbidden()
confirm_write_access()

user_uid = query_param("user_uid")
data_to_sign = f"<User user_id='{user_uid}'/>"
cert = surf_public_signing_certificate(current_app)
private_key = read_file("test/data/privkey.pem")
root = etree.fromstring(data_to_sign)
signed_root = XMLSigner().sign(root, key=private_key, cert=cert)
signed_root_str = etree.tostring(signed_root)
b64encoded_signed_root = base64.b64encode(signed_root_str)
data = {"signed_user": b64encoded_signed_root.decode(),
"continue_url": "https://eb.com"}
# in this flow, we don't want to create a mock-user
session["eb_interrupt_flow"] = True
return data, 200


@mock_user_api.route("stop_interrupt_flow", methods=["DLETE"], strict_slashes=False)
@json_endpoint
def eb_stop_interrupt_flow():
if not os.environ.get("ALLOW_MOCK_USER_API", None):
raise Forbidden()
confirm_write_access()
session["eb_interrupt_flow"] = None
return {}, 200
22 changes: 12 additions & 10 deletions server/api/user_saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from urllib import request
from urllib.parse import urlencode

from flask import Blueprint, current_app, request as current_request, redirect
from flask import Blueprint, current_app, request as current_request, redirect, session
from lxml import etree
from signxml import XMLVerifier

Expand All @@ -11,6 +11,7 @@
from server.api.service_aups import has_agreed_with
from server.auth.mfa import user_requires_sram_mfa, store_user_in_session
from server.auth.service_access import has_user_access_to_service, collaboration_memberships_for_service
from server.auth.surf_conext import surf_public_signing_certificate
from server.auth.user_claims import user_memberships, co_tags
from server.auth.user_codes import UserCode
from server.db.db import db
Expand Down Expand Up @@ -77,14 +78,14 @@ def do_authz_proxy(is_edu_teams: bool):
user = User.query.filter(User.uid == uid).first()
if not user:
free_rider = service.non_member_users_access_allowed
if is_edu_teams:
if not is_edu_teams:
if free_rider:
return {
"status": {
"result": "authorized",
},
"attributes": {}
}
}, 200
else:
parameters["error_status"] = UserCode.USER_UNKNOWN.value
return {
Expand All @@ -94,7 +95,7 @@ def do_authz_proxy(is_edu_teams: bool):
"error_status": UserCode.USER_UNKNOWN.value,
"info": UserCode.USER_UNKNOWN.name
}
}
}, 200
user_code = UserCode.NEW_FREE_RIDE_USER if free_rider else UserCode.USER_UNKNOWN
parameters["error_status"] = user_code.value
return {
Expand Down Expand Up @@ -144,6 +145,7 @@ def do_authz_proxy(is_edu_teams: bool):
"info": UserCode.SERVICE_NOT_CONNECTED.name
}
}, 200

if not has_agreed_with(user, service):
logger.debug(f"Returning interrupt for user {uid} and service_entity_id {service_entity_id} to accept "
f"Service AUP")
Expand Down Expand Up @@ -179,7 +181,9 @@ def do_authz_proxy(is_edu_teams: bool):
all_memberships = user_memberships(user, connected_collaborations)
all_tags = co_tags(connected_collaborations)
all_attributes = all_memberships.union(all_tags)

log_user_login(PROXY_AUTHZ, True, user, uid, service, service_entity_id, "AUTHORIZED")

return {
"status": {
"result": "authorized",
Expand All @@ -198,19 +202,17 @@ def interrupt():
base64_encoded_xml = current_request.form.get("signed_user")
xml = base64.b64decode(base64_encoded_xml).decode()
doc = etree.fromstring(xml)
eb_config = current_app.app_config.engine_block
cert = eb_config.public_key
if not eb_config.public_key.strip():
cert = request.urlopen(eb_config.public_key_url).read()
cert = surf_public_signing_certificate(current_app)
verified_data = XMLVerifier().verify(doc, x509_cert=cert).signed_xml
user_uid = verified_data.attrib.get("user_id")
user = User.query.filter(User.uid == user_uid).one()

user_accepted_aup = user.has_agreed_with_aup()
# Put the user in the session, and pass control back to the GUI
store_user_in_session(user, False, user_accepted_aup)

client_base_url = current_app.app_config.base_url
continue_url = current_request.form.get("continue_url")
# The original destination is returned from both 2mfa endpoint and agree_aup endpoint
session["original_destination"] = continue_url
client_base_url = current_app.app_config.base_url
error_status = query_param("error_status")
return redirect(f"{client_base_url}/interrupt?error_status{error_status}=&continue_url={continue_url}")
9 changes: 9 additions & 0 deletions server/auth/surf_conext.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from urllib import request


def surf_public_signing_certificate(current_app):
eb_config = current_app.app_config.engine_block
cert = eb_config.public_key
if not eb_config.public_key or not eb_config.public_key.strip():
cert = request.urlopen(eb_config.public_key_url).read().decode()
return cert
2 changes: 1 addition & 1 deletion server/config/test_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ excluded_user_accounts:

engine_block:
# Either one is required, if specified both then the public_key is used
public_key_url: https://engine.test.surfconext.nl/authentication/idp/certificate
public_key_url: http://engine.local/idp/certificate
public_key: |
-----BEGIN CERTIFICATE-----
MIIC/zCCAeegAwIBAgIUSeIpDb3Txu37YHhbbUVHiA3s/q4wDQYJKoZIhvcNAQEL
Expand Down
6 changes: 6 additions & 0 deletions server/test/api/test_mock_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ def test_login_new_user(self):

user = self.find_entity_by_name(User, "Boa Dee")
self.assertEqual("[email protected]", user.email)

@allow_for_mock_user_api
def test_eb_interrupt_data(self):
self.login()
res = self.get("/api/mock/interrupt_data", query_data={"user_uid": "urn:sarah"}, with_basic_auth=False)
self.assertEqual("[email protected]", "")
Loading

0 comments on commit 3610e7f

Please sign in to comment.