diff --git a/apps/api/.gitignore b/apps/api/.gitignore index bee8a64b..babd53b3 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -1 +1,3 @@ __pycache__ +*.key +.coverage diff --git a/apps/api/README.md b/apps/api/README.md index 589a1fd9..4af78933 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -17,3 +17,8 @@ which will start a Uvicorn server with auto-reload. For deployment, the following environment variables need to be set: - `PYTHONPATH=src/api` to properly import Python modules +- `SP_KEY`, the private key for SAML authentication + +For staging, the following environment variables should also bet set: + +- `DEPLOYMENT=staging` diff --git a/apps/api/configuration/saml/advanced_settings.json b/apps/api/configuration/saml/advanced_settings.json new file mode 100644 index 00000000..6f5e4622 --- /dev/null +++ b/apps/api/configuration/saml/advanced_settings.json @@ -0,0 +1,42 @@ +{ + "security": { + "nameIdEncrypted": true, + "authnRequestsSigned": true, + "logoutRequestSigned": true, + "logoutResponseSigned": true, + "signMetadata": true, + "wantMessagesSigned": true, + "wantAssertionsSigned": true, + "wantAssertionsEncrypted": true, + "wantNameId": false, + "wantNameIdEncrypted": true, + "wantAttributeStatement": true, + "requestedAuthnContext": true, + "requestedAuthnContextComparison": "exact", + "failOnAuthnContextMismatch": false, + "metadataValidUntil": null, + "metadataCacheDuration": null, + "allowSingleLabelDomains": false, + "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", + "allowRepeatAttributeName": false, + "rejectDeprecatedAlgorithm": true + }, + "contactPerson": { + "technical": { + "givenName": "Hack at UCI", + "emailAddress": "hack@uci.edu" + }, + "support": { + "givenName": "Hack at UCI", + "emailAddress": "hack@uci.edu" + } + }, + "organization": { + "en-US": { + "name": "HackAtUCI", + "displayname": "Hack at UCI", + "url": "https://hack.ics.uci.edu" + } + } +} \ No newline at end of file diff --git a/apps/api/configuration/saml/certs/sp-prod.crt b/apps/api/configuration/saml/certs/sp-prod.crt new file mode 100644 index 00000000..44bc643e --- /dev/null +++ b/apps/api/configuration/saml/certs/sp-prod.crt @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEOTCCAyGgAwIBAgIUZ3pK3QmQN4lwPHQEd1XfG8ZDAtEwDQYJKoZIhvcNAQEL +BQAwgasxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMQ8wDQYDVQQH +DAZJcnZpbmUxKTAnBgNVBAoMIFVuaXZlcnNpdHkgb2YgQ2FsaWZvcm5pYSwgSXJ2 +aW5lMRQwEgYDVQQLDAtIYWNrIGF0IFVDSTEYMBYGA1UEAwwPaXJ2aW5laGFja3Mu +Y29tMRswGQYJKoZIhvcNAQkBFgxoYWNrQHVjaS5lZHUwHhcNMjMxMTIzMTgwODM5 +WhcNMzMxMTIyMTgwODM5WjCBqzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlm +b3JuaWExDzANBgNVBAcMBklydmluZTEpMCcGA1UECgwgVW5pdmVyc2l0eSBvZiBD +YWxpZm9ybmlhLCBJcnZpbmUxFDASBgNVBAsMC0hhY2sgYXQgVUNJMRgwFgYDVQQD +DA9pcnZpbmVoYWNrcy5jb20xGzAZBgkqhkiG9w0BCQEWDGhhY2tAdWNpLmVkdTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAI9wb3qTdkEQEJnVaMfiud0V +sQtKlaHR8jxIMa40plsAoPSzs/VDn+V1CiH/I2Hp8CL7uZcoDIdG9slYyulBN0jP +KvpYrHf5GYxunHk/D+aD8spq1hP5KktiuaNYTDqDNYrHKyyavqFcEMy7ZXUk/rqn +di7tzcLXFJutaSB++ad4cws/XLmJjJz8zn3rbGM+rCHzlZ60bw3y24xjSQhhiy9R +Q+l9SYNJgz1q1M6g1l+9nPDgmtzKoR8f/qFMeQxkfTmmWuyYM2R4bD0FZW2pa2cv +nH4fmeVXaFxyjAK5DvXFCckSt9X2/7efZdI1+MaPoYfZunXR+vOn6oWsq9Zu0DkC +AwEAAaNTMFEwHQYDVR0OBBYEFNO4CEyG3Rbyt9y9MzpauzgffX09MB8GA1UdIwQY +MBaAFNO4CEyG3Rbyt9y9MzpauzgffX09MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBADiItb6TkqFEiZPr2xeGiMjbDqQroNDw4m46D32FiTsr7WkD +r6ZA/lInTSN30xF0cuzJyHh/M1uwwEo1w6Z4D3LWMDJROaiVAaKBAxiJFY5yDAFo +cRO2VmoWLvJryJvyZdG4+ALaHgWYTXnAqZBAfmtaBIATX/Db5rI4+VKSaqqZNLlQ +2s+9T5AlYcNOE/yIQmAIV5Nk319zmUhZbtJD9bmE1RwdE0qwBjIWOHDiNBpRcmES +Umq0tVD/V7oUdE6L3WFOu8EMj5k6667hQcNZJDJwiEpC+brsJO/vBRWA8Skz6YFW +CsdbuJV5/JXU5pzmizrSO7ZIvQJpXo27LTHwI+Y= +-----END CERTIFICATE----- diff --git a/apps/api/configuration/saml/certs/sp-staging.crt b/apps/api/configuration/saml/certs/sp-staging.crt new file mode 100644 index 00000000..cdfb072b --- /dev/null +++ b/apps/api/configuration/saml/certs/sp-staging.crt @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIESTCCAzGgAwIBAgIUXSOSqBVb0kaTHAvtg4BIeaKk31wwDQYJKoZIhvcNAQEL +BQAwgbMxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMQ8wDQYDVQQH +DAZJcnZpbmUxKTAnBgNVBAoMIFVuaXZlcnNpdHkgb2YgQ2FsaWZvcm5pYSwgSXJ2 +aW5lMRQwEgYDVQQLDAtIYWNrIGF0IFVDSTEgMB4GA1UEAwwXc3RhZ2luZy5pcnZp +bmVoYWNrcy5jb20xGzAZBgkqhkiG9w0BCQEWDGhhY2tAdWNpLmVkdTAeFw0yMzEx +MjMxODA5MTFaFw0zMzExMjIxODA5MTFaMIGzMQswCQYDVQQGEwJVUzETMBEGA1UE +CAwKQ2FsaWZvcm5pYTEPMA0GA1UEBwwGSXJ2aW5lMSkwJwYDVQQKDCBVbml2ZXJz +aXR5IG9mIENhbGlmb3JuaWEsIElydmluZTEUMBIGA1UECwwLSGFjayBhdCBVQ0kx +IDAeBgNVBAMMF3N0YWdpbmcuaXJ2aW5laGFja3MuY29tMRswGQYJKoZIhvcNAQkB +FgxoYWNrQHVjaS5lZHUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9 ++thRtbPV5pGNw1ju6A1Ay4fWNZSJOSVExh7uK/f31GwPz+eKgZqg3TEkRvzJO5Bw +Kok19oS5fXji1OvTy5BJEzSZ8rRUkWS5LlFBQTCLkP79+S12ldrbv8ojpsAYPuVa +D7z69U9kmwFsTiS3h6Oqmn/rV0eicmGCFRYAjPSbdcG7zQZJ/HCfLHiblpagKX1X +o2SeNBLkRZAV1uNA3fB8czk68pJ6+yBXH3BIbZUxRarmMDRMd104d4dvrcD90Lja +B+kL3wAM/Iz3NkihRR45F0OZ66Tk9XnBZrdj2eyHMFn5nkVLAdi+nwZld/23ZL06 +9JkTvzeFpzqXltBk8kbnAgMBAAGjUzBRMB0GA1UdDgQWBBTTIK63nY6j+IH2KEiT +rbsdulCI+DAfBgNVHSMEGDAWgBTTIK63nY6j+IH2KEiTrbsdulCI+DAPBgNVHRMB +Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAvxa/E8WJYlkckEMrY7X0u1EhF +xxbHXiS1fXIYrojg4zFLpiulmw06C/09aw4kDaIGwUh8aicyRiA9PtVd8MEuqlhi +C/GlI+PRjc/+5I0RNws4occAcWADEjMt7WHg/iLwuAg/QjmOEhZlA3qh+vYyaSlR +XgoqdMtkulnIdxvXs+n/ZZEE9irVDbqrWF691bzhX1McvqxoIAuMEGUAWUqUhmdl +GIk09JrWyJ9jwCwmObK8sKroNedUW22gh1wG/Bb3IIkNrXfoQukOinPlWmA327vP +Pd8KjVZ0uR/IDRQwzROyU6j8/OVYpjOnEijShmVd74PK6wcDl0PmOnP9DFxT +-----END CERTIFICATE----- diff --git a/apps/api/configuration/saml/settings-prod.json b/apps/api/configuration/saml/settings-prod.json new file mode 100644 index 00000000..1d951b0f --- /dev/null +++ b/apps/api/configuration/saml/settings-prod.json @@ -0,0 +1,60 @@ +{ + "strict": true, + "debug": true, + "sp": { + "entityId": "https://irvinehacks.com/shibboleth", + "assertionConsumerService": { + "url": "https://irvinehacks.com/api/saml/acs", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + }, + "singleLogoutService": { + "url": "https://irvinehacks.com/api/saml/sls", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "attributeConsumingService": { + "serviceName": "IrvineHacks Website", + "serviceDescription": "Website for IrvineHacks, Orange County's largest hackathon.", + "requestedAttributes": [ + { + "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "name": "urn:oid:0.9.2342.19200300.100.1.3", + "isRequired": true, + "friendlyName": "email" + }, + { + "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "name": "urn:oid:2.16.840.1.113730.3.1.241", + "isRequired": true, + "friendlyName": "displayName" + }, + { + "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "name": "urn:oid:2.16.840.1.113916.5.6.1.1", + "isRequired": true, + "friendlyName": "ucinetid" + }, + { + "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "name": "urn:oid:2.16.840.1.113916.5.6.1.59", + "isRequired": true, + "friendlyName": "uciaffiliation" + } + ] + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "x509cert": "", + "privateKey": "" + }, + "idp": { + "entityId": "urn:mace:incommon:uci.edu", + "singleSignOnService": { + "url": "https://shib.service.uci.edu/idp/profile/SAML2/Redirect/SSO", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "singleLogoutService": { + "url": "https://shib.service.uci.edu/logout.html", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "x509cert": "MIIDODCCAiCgAwIBAgIJALjyIupKtkKDMA0GCSqGSIb3DQEBBQUAMBwxGjAYBgNVBAMTEXNoaWIubmFjcy51Y2kuZWR1MB4XDTE2MDcxNTIzMTg1MVoXDTI2MDcxMzIzMTg1MVowHDEaMBgGA1UEAxMRc2hpYi5uYWNzLnVjaS5lZHUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDp964Q5ZWIBmmI/5EfAo1z0S3SBYoLfNl5EkfJrnqntc5VO7AcBjsuNrZTb1zrv/juL7cXXjzcrh1Pbh2BIpwSa3z0yta1KQX3NZBq8wZOjdyd6iLkLx6kcT9q5MyWyd8qJ2DmPQZ9wWdPCb0RoRA8RaJW0gp/3JQhh4+ERVUJx438JA/+dfouSiXB+UguVYHgceETjOik09A/j6cD1shILMZnuBObAtOAuT9PSFiWhOKOLghloAHkc0QDTyXC9BNTBKtyBr9XiYLgvspmy13L3Kc1npPynLt+KqfuBIG0OE/arSOImGD/y6ep6EaB6WTcWaCqNaQ3l4bQX8RaLMp5AgMBAAGjfTB7MB0GA1UdDgQWBBTvY0+ZHT/jnKj8RLLgCpDcTowcCzBMBgNVHSMERTBDgBTvY0+ZHT/jnKj8RLLgCpDcTowcC6EgpB4wHDEaMBgGA1UEAxMRc2hpYi5uYWNzLnVjaS5lZHWCCQC48iLqSrZCgzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBTuqm1sr3CNlHMKaou7THCv+ADueR7mE+25C0vG8cEF+5GQ1Rh3dGPqXPmnt/D1R8zKhm5XqvkjXTvdE4cvq1hdpwADhDAa9IOYbnRCrbeajfZFUG0tAe5mFayBhKKmSVdM73n535nQ2NvTImGgO9bkD1Nss/Yi1WfCWUdoYf6zhE8LP6zQEE75vYHD5nmYpQ/k3Yw3dxkifIjuUZf/eTibB26/FRM4cdv05EYBQ88U4+0PzRg8ZRqvZ2Cj0qub7eRQNt6Jfm4/QsEos3q2PxBLbvTGUtZ1RizkEWrhF5bk0Ax8xJFkh64xXidpKNgt8Bl6IfZDpHXUZTdfjCTDRRD" + } +} diff --git a/apps/api/configuration/saml/settings-staging.json b/apps/api/configuration/saml/settings-staging.json new file mode 100644 index 00000000..46b6b75e --- /dev/null +++ b/apps/api/configuration/saml/settings-staging.json @@ -0,0 +1,60 @@ +{ + "strict": true, + "debug": true, + "sp": { + "entityId": "https://staging.irvinehacks.com/shibboleth", + "assertionConsumerService": { + "url": "https://staging.irvinehacks.com/api/saml/acs", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + }, + "singleLogoutService": { + "url": "https://staging.irvinehacks.com/api/saml/sls", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "attributeConsumingService": { + "serviceName": "IrvineHacks Website", + "serviceDescription": "Website for IrvineHacks, Orange County's largest hackathon.", + "requestedAttributes": [ + { + "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "name": "urn:oid:0.9.2342.19200300.100.1.3", + "isRequired": true, + "friendlyName": "email" + }, + { + "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "name": "urn:oid:2.16.840.1.113730.3.1.241", + "isRequired": true, + "friendlyName": "displayName" + }, + { + "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "name": "urn:oid:2.16.840.1.113916.5.6.1.1", + "isRequired": true, + "friendlyName": "ucinetid" + }, + { + "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "name": "urn:oid:2.16.840.1.113916.5.6.1.59", + "isRequired": true, + "friendlyName": "uciaffiliation" + } + ] + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "x509cert": "", + "privateKey": "" + }, + "idp": { + "entityId": "https://shib-qa.service.uci.edu/idp", + "singleSignOnService": { + "url": "https://shib-qa.service.uci.edu/idp/profile/SAML2/Redirect/SSO", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "singleLogoutService": { + "url": "https://shib-qa.service.uci.edu/logout.html", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "x509cert": "MIIDJzCCAg+gAwIBAgIUU1688ql6Nw4jpeO9aKzQTz4eTCUwDQYJKoZIhvcNAQELBQAwIzEhMB8GA1UEAwwYc2hpYi1kZXYuc2VydmljZS51Y2kuZWR1MB4XDTIxMDMxNjIxMjUyMVoXDTMxMDMxNDIxMjUyMVowIzEhMB8GA1UEAwwYc2hpYi1kZXYuc2VydmljZS51Y2kuZWR1MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo673SK1qbWNcQbTNzTz9j4hwQF5W+VwFsaHOa+YqtngRGjbXWrgwuf15pUgLz/Mzuqg8j8I46VAaTXd5kdPmhN0GbLxWmVQDgUjMEZzGk50LATvmx9abt3YR8JhvlVtgLAYCssjp3LA8QhoZJu0DsJJ8uMHM9xXtrktotYp8/PHoJekMsmPYjZk41Semz63H87wv79faREeBznpj1BNNeCqGHCV/OhsBnhuDjD7/xei1DH7fqHD2p/CSpti5/2GL+X60n7yuqe3SkQaHJ/fmUvsc2TVsmJ2GB9/tYbLaBnpeIR/W6Td9e8hB8t4/OS0tQFQlpdvuNg2mFEWyeChIBwIDAQABo1MwUTAdBgNVHQ4EFgQU4l1TIJmWzfA8aVWBM0z64ahRfFwwHwYDVR0jBBgwFoAU4l1TIJmWzfA8aVWBM0z64ahRfFwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAIgqAD8S9io6RR3vIvjMGcWtW7bE0ICAV9sVm2bXXB5mLBuDCGS46TEy8tVoLw5/56LfM6x25EF62/uncnFHQw/8b5r+45J6jVclT0YhHtwWeqKO3GJ7dzxXIdXCsNLPZ17/rL/wKQhUTuKDtFgOko3Oq4xsZM0X3ObsJD+t1VmgVcoTweai4LyiyX0k+vxX2F2EEXI7AWN0qzuQiBhp4pWGfSXRKCHEpQwd8+sXcYbn3VflbSXaMifPqWm5JRzsmUT6pA4XFxoux9/JAKciFuDeD/zWgM5HinqooIwg3SlHzjcOCK07ZcQ1fBTvnbS2NfVwwQmtCQHzLL8VoLFDaZw==" + } +} diff --git a/apps/api/package.json b/apps/api/package.json index 208234bc..00f0c30e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -2,6 +2,7 @@ "name": "api", "private": true, "scripts": { - "dev": "python src/dev.py" + "dev": "python src/dev.py", + "test": "pytest" } -} +} \ No newline at end of file diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 00000000..040d09ce --- /dev/null +++ b/apps/api/pyproject.toml @@ -0,0 +1,11 @@ +[tool.pytest.ini_options] +pythonpath = "src" +addopts = "--verbose --cov src" +testpaths = "tests" +asyncio_mode = "auto" + +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true diff --git a/apps/api/requirements-dev.txt b/apps/api/requirements-dev.txt index d0b466f9..206a8bb8 100644 --- a/apps/api/requirements-dev.txt +++ b/apps/api/requirements-dev.txt @@ -1 +1,5 @@ +pytest==7.4.3 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 + uvicorn[standard]==0.23.2 diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 2caddd33..ca0d5625 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -1,2 +1,4 @@ fastapi==0.104.1 +httpx==0.25.2 python-multipart==0.0.5 +python3-saml==1.16.0 diff --git a/apps/api/src/app.py b/apps/api/src/app.py index 62810acb..9a527bb1 100644 --- a/apps/api/src/app.py +++ b/apps/api/src/app.py @@ -1,10 +1,10 @@ from fastapi import FastAPI -from routers import demo +from routers import saml app = FastAPI() -app.include_router(demo.router, prefix="/demo", tags=["demo"]) +app.include_router(saml.router, prefix="/saml", tags=["saml"]) @app.get("/") diff --git a/apps/api/src/routers/saml.py b/apps/api/src/routers/saml.py new file mode 100644 index 00000000..74d84fcb --- /dev/null +++ b/apps/api/src/routers/saml.py @@ -0,0 +1,152 @@ +import json +import os +from functools import lru_cache +from logging import getLogger +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse, Response +from onelogin.saml2.auth import OneLogin_Saml2_Auth, OneLogin_Saml2_Settings + +# from auth import user_identity + +log = getLogger(__name__) + +router = APIRouter() + +STAGING_ENV = os.getenv("DEPLOYMENT") == "STAGING" +SP_CRT = os.getenv("SP_CRT") +SP_KEY = os.getenv("SP_KEY") + + +@lru_cache +def _get_saml_settings() -> OneLogin_Saml2_Settings: + """ + Loads settings along with SP certificate and key. + Similar to OneLogin_Saml2_Settings._load_settings_from_file, + but chooses values based on staging or production environment + and can load values from environment variables instead of files. + """ + BASE_PATH = Path("configuration/saml") + + if not SP_KEY: + raise ValueError("SP_KEY is not defined") + + def _read_json(filename: str) -> dict[str, Any]: + with open(BASE_PATH / filename) as file: + data: dict[str, Any] = json.load(file) + return data + + settings_filename = "settings-staging.json" if STAGING_ENV else "settings-prod.json" + advanced_settings_filename = "advanced_settings.json" + settings = { + **_read_json(settings_filename), + **_read_json(advanced_settings_filename), + } + + settings["sp"]["x509cert"] = settings["sp"]["x509cert"] or SP_CRT + if not settings["sp"]["x509cert"]: + sp_crt_filename = "sp-staging.crt" if STAGING_ENV else "sp-prod.crt" + with open(BASE_PATH / "certs" / sp_crt_filename) as sp_crt_file: + settings["sp"]["x509cert"] = sp_crt_file.read() + + settings["sp"]["privateKey"] = SP_KEY + + return OneLogin_Saml2_Settings(settings, custom_base_path=str(BASE_PATH)) + + +async def _prepare_saml_req(req: Request) -> dict[str, Any]: + """Packages a FastAPI Request into a request dict for SAML Auth""" + return { + "http_host": req.url.hostname, + "script_name": req.url.path, + "get_data": req.query_params, + "post_data": await req.form(), + # Advanced request options + "https": "on", + # "request_uri": "", + # "query_string": "", + # "validate_signature_from_qs": False, + # "lowercase_urlencoding": False, + } + + +async def _get_saml_auth(req: Request) -> OneLogin_Saml2_Auth: + """Initializes a SAML Auth instance based on the request""" + request_data = await _prepare_saml_req(req) + settings = _get_saml_settings() + + return OneLogin_Saml2_Auth(request_data, old_settings=settings) + + +@router.get("/login") +async def login(req: Request) -> RedirectResponse: + auth = await _get_saml_auth(req) + sso_url = auth.login() + + # Redirect user to SSO url to complete authentication + return RedirectResponse(sso_url) + + +@router.post("/acs") +async def acs(req: Request) -> RedirectResponse: + """ + SAML Assertion Consumer Service. + Accepts the response returned by the SAML Identity Provider and + sets a cookie with a JWT token which validates the user's identity. + """ + auth = await _get_saml_auth(req) + auth.process_response() + errors = auth.get_errors() + + if errors: + log.error(f"SAML Error: {', '.join(errors)}, {auth.get_last_error_reason()}") + raise HTTPException(500, "An error occurred while processing the SAML response") + + if not auth.is_authenticated(): + log.warning("SAML Response received but user is not authenticated") + raise HTTPException(401, "User was not authenticated") + + log.info(f"User Authenticated with SAML: {auth.get_friendlyname_attributes()}") + try: + (email,) = auth.get_friendlyname_attribute("email") + (display_name,) = auth.get_friendlyname_attribute("displayName") + (ucinetid,) = auth.get_friendlyname_attribute("ucinetid") + affiliations: list[str] = auth.get_friendlyname_attribute("uciaffiliation") + except (ValueError, TypeError) as e: + log.exception("Error decoding SAML Attributes: %s", e) + raise HTTPException(500, "Error decoding user identity") + + # user = user_identity.NativeUser( + # ucinetid=ucinetid, + # display_name=display_name, + # email=email, + # affiliations=affiliations, + # ) + + # res = RedirectResponse("/", status_code=303) + # user_identity.issue_user_identity(user, res) + return f"Hello, {display_name} ({ucinetid})" + + +@router.get("/sls") +async def sls(req: Request) -> str: + """SAML Single Logout Service, not yet implemented""" + # auth = await _get_saml_auth(req) + # auth.logout() + return "SAML SLS" + + +@router.get("/metadata") +async def get_saml_metadata() -> Response: + """Provides SAML metadata, used when registering service with IdP""" + saml_settings = _get_saml_settings() + metadata: bytes = saml_settings.get_sp_metadata() + + errors = saml_settings.validate_metadata(metadata) + if errors: + log.error(f"Error found on Metadata: {', '.join(errors)}") + raise HTTPException(500, "Could not prepare SP metadata") + + return Response(metadata, media_type="application/xml") diff --git a/apps/api/tests/test_saml.py b/apps/api/tests/test_saml.py new file mode 100644 index 00000000..1c199d0e --- /dev/null +++ b/apps/api/tests/test_saml.py @@ -0,0 +1,61 @@ +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient +from onelogin.saml2.auth import OneLogin_Saml2_Settings + +from routers import saml + +SSO_URL = "https://shib.service.uci.edu/idp/profile/SAML2/Redirect/SSO" +SAMPLE_SETTINGS = OneLogin_Saml2_Settings( + { + "sp": { + "entityId": "https://irvinehacks.com/shibboleth", + "assertionConsumerService": { + "url": "https://irvinehacks.com/api/saml/acs", + }, + }, + "idp": { + "entityId": "urn:mace:incommon:uci.edu", + "singleSignOnService": { + "url": SSO_URL, + }, + }, + } +) + +client = TestClient(saml.router) + + +@patch("routers.saml._get_saml_settings") +def test_saml_login_redirects(mock_get_saml_settings: MagicMock) -> None: + """Tests that the login route redirects to the UCI Shibboleth SSO page""" + mock_get_saml_settings.return_value = SAMPLE_SETTINGS + res = client.get("/login", follow_redirects=False) + assert res.status_code == 307 + assert res.headers["location"].startswith(SSO_URL) + + +@patch("routers.saml._get_saml_auth") +def test_saml_acs_succeeds(mock_get_saml_auth: MagicMock) -> None: + """Tests that the ACS route can process a valid auth request""" + mock_auth = MagicMock() + mock_get_saml_auth.return_value = mock_auth + + mock_auth.get_errors.return_value = [] + mock_auth.is_authenticated.return_value = True + mock_auth.get_friendlyname_attribute.side_effect = [ + ["hack@uci.edu"], + ["Hack at UCI"], + ["hack"], + ["group"], + ] + + res = client.post("/acs", follow_redirects=False) + mock_auth.process_response.assert_called() + + assert res.text == '"Hello, Hack at UCI (hack)"' + # check that user is redirected to main page + # assert res.status_code == 303 + # assert res.headers["location"] == "/" + # check that response sets appropriate cookie + # assert res.headers["Set-Cookie"].startswith("hackuci_auth=") diff --git a/apps/site/copy-api.sh b/apps/site/copy-api.sh index 144b56d0..dc8416eb 100755 --- a/apps/site/copy-api.sh +++ b/apps/site/copy-api.sh @@ -1,4 +1,4 @@ # Copy API files from apps/api -cp -R ../api/requirements.txt . +cp -R ../api/{requirements.txt,configuration} . cp -R ../api/src/ src/api/ cp ../api/index.py api/index.py diff --git a/apps/site/vercel-lib.sh b/apps/site/vercel-lib.sh new file mode 100755 index 00000000..55106c02 --- /dev/null +++ b/apps/site/vercel-lib.sh @@ -0,0 +1,26 @@ +# Install python3-saml dependencies on Vercel's Serverless Function +# environment (Amazon Linux) + +yum install -y libxml2-devel xmlsec1-devel xmlsec1-openssl-devel libtool-ltdl-devel +lib_files=( + libltdl.so.7 + libltdl.so.7.3.0 + libxml2.so.2 + libxml2.so.2.9.1 + libxmlsec1-openssl.so + libxmlsec1-openssl.so.1.2.20 + libxmlsec1.so.1 + libxmlsec1.so.1.2.20 + libxslt.so.1 + libxslt.so.1.1.28 +) + +mkdir lib +for file in "${lib_files[@]}" +do + cp /usr/lib64/$file lib/ +done + +echo $PWD +ls +ls lib diff --git a/apps/site/vercel.json b/apps/site/vercel.json index 97741781..12797496 100644 --- a/apps/site/vercel.json +++ b/apps/site/vercel.json @@ -1,5 +1,5 @@ { - "buildCommand": "cd ../.. && turbo run build --filter={apps/site} && cd apps/site && ./copy-api.sh", + "buildCommand": "cd ../.. && turbo run build --filter={apps/site} && cd apps/site && ./copy-api.sh && ./vercel-lib.sh", "functions": { "api/index.py": { "memory": 512, diff --git a/package.json b/package.json index 03d66b87..9baad316 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", + "test": "turbo run test", "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "devDependencies": { diff --git a/turbo.json b/turbo.json index b1e2b605..487e5f1f 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,7 @@ "dev": { "cache": false, "persistent": true - } + }, + "test": {} } }