Compare commits
45 Commits
main
...
report_mig
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c5a7f0311 | |||
| c308171de5 | |||
| 03dfdd6218 | |||
| a757799724 | |||
| 61bea5fb6e | |||
| c835a9deb0 | |||
| b862a3f686 | |||
| bc00515c1b | |||
| 60f9aa05a0 | |||
| 4705649ae8 | |||
| 5acaffc224 | |||
| 0a1b901b12 | |||
| 440c13cc49 | |||
| 9b9557105c | |||
| af360d7793 | |||
| 452e6189e4 | |||
| 7e4fb66fc6 | |||
| 213f560c4a | |||
| 8449578424 | |||
| 1adcc04efc | |||
| 0e5de99f55 | |||
| 0dae53017d | |||
| 934937feb0 | |||
| dcb6850306 | |||
| f264f90f3b | |||
| cd11b389e5 | |||
| b3836639bf | |||
| 6f1fb56670 | |||
| 8ef8bf5d92 | |||
| d684931bc5 | |||
| 3cf5c83264 | |||
| ebeaaa2629 | |||
| b84dbc9a56 | |||
| 1817d02c31 | |||
| 4afe00e1f8 | |||
| 60f5450f47 | |||
| d6dc33d9b1 | |||
| a4daf7e204 | |||
| dcb40473da | |||
| 4cbd510b85 | |||
| f0481d090c | |||
| 4f7c06ed97 | |||
| 46183766e5 | |||
| b6c9ee4bf8 | |||
| 0080857d4e |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -18,8 +18,19 @@
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/build/
|
||||
/build/classes/*
|
||||
!/build/WebContent/
|
||||
!/build/WebContent/**
|
||||
/build/WebContent/ReportsBackup/
|
||||
/build/WebContent/WEB-INF/lib/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
/cygnus-onprem-app/target
|
||||
/cygnus-cloud-client/target
|
||||
/cygnus-cloud-service/target
|
||||
/cygnus-installer/src/target
|
||||
/cygnus-installer/target
|
||||
/cygnus-onprem-db/target
|
||||
/cygnus-lib/target
|
||||
|
||||
87
.vscode/launch.json
vendored
Normal file
87
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "java",
|
||||
"name": "Cygnus Cloud Service (JDK 21)",
|
||||
"request": "launch",
|
||||
"mainClass": "com.cygnus.cloud.CygnusCloudServiceApplication",
|
||||
"projectName": "cygnus-cloud-service",
|
||||
"cwd": "${workspaceFolder}/cygnus-cloud-service",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home",
|
||||
"DB_HOST": "103.125.129.116",
|
||||
"DB_PORT": "5333",
|
||||
"DB_NAME": "matrix",
|
||||
"DB_USER": "postgres",
|
||||
"DB_PASSWORD": "M@triXPostgr3s@6202",
|
||||
"DB_SSL": "false",
|
||||
"REDIS_HOST": "103.125.129.116",
|
||||
"REDIS_PORT": "7901",
|
||||
"REDIS_PASSWORD": "M@triXR3d1s@6202",
|
||||
"CYGNUS_CLOUD_PORT": "8090",
|
||||
"CYGNUS_SECURITY_ENABLED": "true",
|
||||
"CYGNUS_JWT_ISSUER_URI": "http://localhost:8090",
|
||||
"CYGNUS_JWT_AUDIENCE": "cygnus-cloud-api",
|
||||
"CYGNUS_TOKEN_AUDIENCE": "http://localhost:8090/oauth2/token",
|
||||
"CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/assertion-decryption-private.pem",
|
||||
"CYGNUS_ACCESS_TOKEN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/access-token-private.pem",
|
||||
"CYGNUS_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem",
|
||||
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
|
||||
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem",
|
||||
"CYGNUS_MAIL_HOST": "smtp.gmail.com",
|
||||
"CYGNUS_MAIL_PORT": "587",
|
||||
"CYGNUS_MAIL_USERNAME": "technobeesolutions@gmail.com",
|
||||
"CYGNUS_MAIL_PASSWORD": "lrideibfakickldg",
|
||||
"CYGNUS_MAIL_SMTP_AUTH": "true",
|
||||
"CYGNUS_MAIL_STARTTLS": "true",
|
||||
"CYGNUS_REGISTRATION_EMAIL_FROM": "technobeesolutions@gmail.com"
|
||||
},
|
||||
"shortenCommandLine": "argfile"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "Cygnus On-Prem - Tomcat 10 (JDK 21)",
|
||||
"request": "launch",
|
||||
"mainClass": "matrix.nimble.EmbeddedTomcatServer",
|
||||
"cwd": "${workspaceFolder}/cygnus-onprem-app",
|
||||
"console": "integratedTerminal",
|
||||
"preLaunchTask": "cygnus: dev compile",
|
||||
"classPaths": [
|
||||
"$Test"
|
||||
],
|
||||
"env": {
|
||||
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home",
|
||||
"MATRIX_DB_URL": "jdbc:postgresql://103.125.129.116:5333/matrix",
|
||||
"MATRIX_DB_USERNAME": "postgres",
|
||||
"MATRIX_DB_PASSWORD": "M@triXPostgr3s@6202",
|
||||
"REDIS_HOST": "103.125.129.116",
|
||||
"REDIS_PORT": "7901",
|
||||
"REDIS_PASSWORD": "M@triXR3d1s@6202",
|
||||
"REDIS_DATABASE": "1",
|
||||
"REDIS_SSL": "false",
|
||||
"CYGNUS_QUERY_CACHE_ENABLED": "false",
|
||||
"CYGNUS_CLOUD_BASE_URL": "http://localhost:8090",
|
||||
"CYGNUS_TOKEN_URL": "http://localhost:8090/oauth2/token",
|
||||
"CYGNUS_CLIENT_ID": "matrix",
|
||||
"CYGNUS_INSTALLATION_ID": "matrix-delhi-cygnus-01",
|
||||
"CYGNUS_CLIENT_ASSERTION": "file:${workspaceFolder}/config/clients/matrix/matrix-matrix-delhi-cygnus-01-assertion.jwt",
|
||||
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
|
||||
"CYGNUS_LOGIN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/login-public.pem",
|
||||
"CYGNUS_PAYLOAD_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/case-save-private.pem",
|
||||
"CYGNUS_PAYLOAD_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/case-save-public.pem",
|
||||
"CYGNUS_CLOUD_REQUEST_TIMEOUT": "PT30S",
|
||||
"CYGNUS_TOKEN_REFRESH_SKEW": "PT30S"
|
||||
},
|
||||
"vmArgs": "-Dserver.port=8080 -Djava.awt.headless=true -Dmatrix.webapp=${workspaceFolder}/cygnus-onprem-app/build/WebContent -Dmatrix.classes=${workspaceFolder}/cygnus-onprem-app/target/classes",
|
||||
"shortenCommandLine": "argfile"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "Current Java File",
|
||||
"request": "launch",
|
||||
"mainClass": "${file}"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic",
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
}
|
||||
24
.vscode/tasks.json
vendored
Normal file
24
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "cygnus: dev compile",
|
||||
"type": "shell",
|
||||
"command": "mvn",
|
||||
"args": [
|
||||
"test-compile",
|
||||
"-DskipTests"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home"
|
||||
}
|
||||
},
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
"$javac"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
359
Cygnus_Architecture_Overview.svg
Normal file
359
Cygnus_Architecture_Overview.svg
Normal file
@@ -0,0 +1,359 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="3720" viewBox="0 0 1920 3720" role="img" aria-labelledby="title description">
|
||||
<title id="title">Cygnus on-premises to cloud authentication architecture overview</title>
|
||||
<desc id="description">Complete architecture, request flow, sequence diagram, security layers, code navigation, data access, session mapping, and failure behavior for Cygnus cloud identity authentication.</desc>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="pageBg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#eaf2f8"/>
|
||||
<stop offset="1" stop-color="#d9e7f1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="heroBg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#173b5f"/>
|
||||
<stop offset="1" stop-color="#28679b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cloudBg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#e7f2fb"/>
|
||||
<stop offset="1" stop-color="#d2e7f7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="onpremBg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#eef8f2"/>
|
||||
<stop offset="1" stop-color="#dcefe3"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="5" stdDeviation="8" flood-color="#1c3448" flood-opacity=".16"/>
|
||||
</filter>
|
||||
<marker id="arrowBlue" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L12,6 L0,12 z" fill="#28679b"/>
|
||||
</marker>
|
||||
<marker id="arrowGreen" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L12,6 L0,12 z" fill="#26845b"/>
|
||||
</marker>
|
||||
<marker id="arrowOrange" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L12,6 L0,12 z" fill="#d47b17"/>
|
||||
</marker>
|
||||
<style>
|
||||
text { font-family: Inter, "Segoe UI", Arial, sans-serif; fill: #22303d; }
|
||||
.hero-title { font-size: 46px; font-weight: 750; fill: #fff; letter-spacing: .2px; }
|
||||
.hero-sub { font-size: 22px; fill: #d7eafb; }
|
||||
.section-title { font-size: 30px; font-weight: 750; fill: #173b5f; }
|
||||
.section-sub { font-size: 17px; fill: #536777; }
|
||||
.box-title { font-size: 20px; font-weight: 750; }
|
||||
.box-sub { font-size: 14px; fill: #5a6f7f; }
|
||||
.body { font-size: 16px; }
|
||||
.small { font-size: 14px; }
|
||||
.tiny { font-size: 12px; }
|
||||
.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 13px; }
|
||||
.white { fill: #fff; }
|
||||
.muted { fill: #64798a; }
|
||||
.blue { fill: #28679b; }
|
||||
.green { fill: #26845b; }
|
||||
.orange { fill: #c76c0f; }
|
||||
.red { fill: #b74646; }
|
||||
.bold { font-weight: 700; }
|
||||
.card { fill: #fff; stroke: #b8cad8; stroke-width: 1.4; rx: 12; filter: url(#shadow); }
|
||||
.soft-card { fill: #f7fafc; stroke: #c5d5e1; stroke-width: 1.2; rx: 10; }
|
||||
.onprem { fill: url(#onpremBg); stroke: #83b99b; stroke-width: 1.5; rx: 12; }
|
||||
.cloud { fill: url(#cloudBg); stroke: #81b2d5; stroke-width: 1.5; rx: 12; }
|
||||
.security { fill: #fff6e7; stroke: #e2ae62; stroke-width: 1.4; rx: 10; }
|
||||
.database { fill: #f2ecfa; stroke: #a991c6; stroke-width: 1.4; rx: 10; }
|
||||
.line-blue { stroke: #28679b; stroke-width: 3; fill: none; marker-end: url(#arrowBlue); }
|
||||
.line-green { stroke: #26845b; stroke-width: 3; fill: none; marker-end: url(#arrowGreen); }
|
||||
.line-orange { stroke: #d47b17; stroke-width: 3; fill: none; marker-end: url(#arrowOrange); }
|
||||
.dash { stroke-dasharray: 8 7; }
|
||||
.lane { stroke: #9bb0bf; stroke-width: 1.5; stroke-dasharray: 6 6; }
|
||||
.step-circle { fill: #28679b; stroke: #fff; stroke-width: 3; }
|
||||
.step-number { font-size: 14px; font-weight: 750; fill: #fff; text-anchor: middle; dominant-baseline: central; }
|
||||
.chip { fill: #e5eff7; stroke: #aec4d5; stroke-width: 1; rx: 14; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1920" height="3720" fill="url(#pageBg)"/>
|
||||
|
||||
<!-- Hero -->
|
||||
<rect x="50" y="42" width="1820" height="190" rx="20" fill="url(#heroBg)" filter="url(#shadow)"/>
|
||||
<text x="105" y="112" class="hero-title">Cygnus Architecture Overview</text>
|
||||
<text x="105" y="157" class="hero-sub">Secure on-premises → cloud identity authentication while operational and applicant data remain on customer infrastructure</text>
|
||||
<rect x="105" y="180" width="212" height="30" class="chip" opacity=".22"/>
|
||||
<text x="123" y="201" class="small white bold">JDK 21 · Spring MVC</text>
|
||||
<rect x="330" y="180" width="245" height="30" class="chip" opacity=".22"/>
|
||||
<text x="348" y="201" class="small white bold">Spring WebFlux client</text>
|
||||
<rect x="588" y="180" width="280" height="30" class="chip" opacity=".22"/>
|
||||
<text x="606" y="201" class="small white bold">Spring Boot cloud service</text>
|
||||
<rect x="881" y="180" width="260" height="30" class="chip" opacity=".22"/>
|
||||
<text x="899" y="201" class="small white bold">PostgreSQL + Vert.x</text>
|
||||
<rect x="1154" y="180" width="212" height="30" class="chip" opacity=".22"/>
|
||||
<text x="1172" y="201" class="small white bold">Redis replay guard</text>
|
||||
|
||||
<!-- Architecture overview -->
|
||||
<text x="70" y="292" class="section-title">1 · Architecture overview</text>
|
||||
<text x="70" y="322" class="section-sub">The browser-facing application remains on-premises. Authentication and menu ownership are delegated to the cloud through a reusable client.</text>
|
||||
|
||||
<rect x="70" y="352" width="1780" height="490" class="card"/>
|
||||
<rect x="95" y="377" width="1050" height="440" class="onprem"/>
|
||||
<text x="125" y="414" class="box-title green">CUSTOMER PREMISES</text>
|
||||
<text x="125" y="439" class="box-sub">Applicant PII, punching, operations, reporting/MIS, JSP UI and HTTP session</text>
|
||||
|
||||
<rect x="130" y="480" width="210" height="126" class="soft-card"/>
|
||||
<text x="235" y="520" class="box-title" text-anchor="middle">Browser</text>
|
||||
<text x="235" y="550" class="small" text-anchor="middle">Login form / JSP UI</text>
|
||||
<text x="235" y="576" class="tiny muted" text-anchor="middle">POST /ver/authenticatelogin</text>
|
||||
|
||||
<rect x="430" y="455" width="300" height="178" class="soft-card"/>
|
||||
<text x="580" y="495" class="box-title" text-anchor="middle">cygnus-onprem-app</text>
|
||||
<text x="580" y="524" class="small" text-anchor="middle">Spring MVC + embedded Tomcat</text>
|
||||
<text x="580" y="552" class="tiny muted" text-anchor="middle">SessionController</text>
|
||||
<text x="580" y="575" class="tiny muted" text-anchor="middle">CloudAuthenticationGateway</text>
|
||||
<text x="580" y="598" class="tiny muted" text-anchor="middle">CloudSessionMapper</text>
|
||||
|
||||
<rect x="820" y="455" width="290" height="178" class="security"/>
|
||||
<text x="965" y="495" class="box-title" text-anchor="middle">cygnus-cloud-client</text>
|
||||
<text x="965" y="524" class="small" text-anchor="middle">Reusable WebClient gateway</text>
|
||||
<text x="965" y="552" class="tiny muted" text-anchor="middle">OAuthMachineTokenProvider</text>
|
||||
<text x="965" y="575" class="tiny muted" text-anchor="middle">LoginEnvelopeEncryptor</text>
|
||||
<text x="965" y="598" class="tiny muted" text-anchor="middle">CloudIdentityClient</text>
|
||||
|
||||
<path d="M340 543 H420" class="line-green"/>
|
||||
<text x="380" y="528" class="tiny green" text-anchor="middle">credentials</text>
|
||||
<path d="M730 543 H810" class="line-green"/>
|
||||
<text x="770" y="528" class="tiny green" text-anchor="middle">delegate</text>
|
||||
|
||||
<rect x="130" y="676" width="980" height="102" class="soft-card"/>
|
||||
<text x="155" y="711" class="box-title">Existing on-premises application behavior retained</text>
|
||||
<text x="155" y="741" class="small">Cloud response → legacy SessionValues + menu HTML → existing authorization, controllers, JSP pages and operational database access.</text>
|
||||
|
||||
<rect x="1190" y="377" width="635" height="440" class="cloud"/>
|
||||
<text x="1220" y="414" class="box-title blue">CYGNUS CLOUD</text>
|
||||
<text x="1220" y="439" class="box-sub">Vendor-controlled machine authorization, identity, permissions and menu data</text>
|
||||
|
||||
<rect x="1225" y="470" width="260" height="140" class="security"/>
|
||||
<text x="1355" y="507" class="box-title" text-anchor="middle">OAuth endpoint</text>
|
||||
<text x="1355" y="536" class="small" text-anchor="middle">POST /oauth2/token</text>
|
||||
<text x="1355" y="564" class="tiny muted" text-anchor="middle">Validate nested machine assertion</text>
|
||||
<text x="1355" y="586" class="tiny muted" text-anchor="middle">Issue short-lived RS256 JWT</text>
|
||||
|
||||
<rect x="1525" y="470" width="265" height="140" class="cloud"/>
|
||||
<text x="1657" y="507" class="box-title" text-anchor="middle">Identity API</text>
|
||||
<text x="1657" y="536" class="small" text-anchor="middle">POST /api/v1/identity/login</text>
|
||||
<text x="1657" y="564" class="tiny muted" text-anchor="middle">Decrypt login envelope</text>
|
||||
<text x="1657" y="586" class="tiny muted" text-anchor="middle">Authenticate and load menu</text>
|
||||
|
||||
<rect x="1225" y="660" width="260" height="110" class="database"/>
|
||||
<text x="1355" y="699" class="box-title" text-anchor="middle">Redis</text>
|
||||
<text x="1355" y="728" class="small" text-anchor="middle">Nonce replay protection</text>
|
||||
<text x="1355" y="751" class="tiny muted" text-anchor="middle">Atomic claim + short TTL</text>
|
||||
|
||||
<rect x="1525" y="660" width="265" height="110" class="database"/>
|
||||
<text x="1657" y="699" class="box-title" text-anchor="middle">PostgreSQL</text>
|
||||
<text x="1657" y="728" class="small" text-anchor="middle">matrix.identity schema</text>
|
||||
<text x="1657" y="751" class="tiny muted" text-anchor="middle">Vert.x prepared queries</text>
|
||||
|
||||
<path d="M1110 520 C1160 520 1160 520 1215 520" class="line-blue"/>
|
||||
<text x="1162" y="504" class="tiny blue" text-anchor="middle">machine assertion</text>
|
||||
<path d="M1110 585 C1320 625 1420 570 1515 540" class="line-blue"/>
|
||||
<text x="1320" y="605" class="tiny blue" text-anchor="middle">Bearer JWT + encrypted login</text>
|
||||
<path d="M1657 610 V650" class="line-blue"/>
|
||||
<path d="M1600 610 C1515 640 1450 670 1490 710" class="line-blue"/>
|
||||
|
||||
<!-- Responsibility split -->
|
||||
<text x="70" y="912" class="section-title">2 · Responsibility and data boundary</text>
|
||||
<text x="70" y="942" class="section-sub">The split is deliberate: identity and non-PI control data can be cloud-managed without transferring loan-applicant PII.</text>
|
||||
<rect x="70" y="972" width="855" height="265" class="card"/>
|
||||
<text x="105" y="1014" class="box-title green">Remains on customer premises</text>
|
||||
<text x="115" y="1054" class="body">• Punching and applicant intake</text>
|
||||
<text x="115" y="1086" class="body">• Operational allocation, scanning, telecalling and reports</text>
|
||||
<text x="115" y="1118" class="body">• Applicant PII, addresses, documents, photos and verification content</text>
|
||||
<text x="115" y="1150" class="body">• MIS generation and existing JSP/HTTP-session application behavior</text>
|
||||
<text x="115" y="1182" class="body">• Existing operational SQL, functions and views unrelated to cloud identity</text>
|
||||
|
||||
<rect x="945" y="972" width="905" height="265" class="card"/>
|
||||
<text x="980" y="1014" class="box-title blue">Managed by Cygnus cloud</text>
|
||||
<text x="990" y="1054" class="body">• Customer installation identity and machine authorization</text>
|
||||
<text x="990" y="1086" class="body">• Application users, groups, companies and branches required at login</text>
|
||||
<text x="990" y="1118" class="body">• Page permissions, denied pages and menu structure</text>
|
||||
<text x="990" y="1150" class="body">• Authentication audit entry and cloud access-token issuance</text>
|
||||
<text x="990" y="1182" class="body">• Redis nonce state used to reject replayed login envelopes</text>
|
||||
|
||||
<!-- Full runtime sequence -->
|
||||
<text x="70" y="1307" class="section-title">3 · Full authentication sequence</text>
|
||||
<text x="70" y="1337" class="section-sub">Every arrow below is part of one browser login. The access token is cached and reused until shortly before expiry.</text>
|
||||
|
||||
<rect x="70" y="1367" width="1780" height="1040" class="card"/>
|
||||
|
||||
<!-- Lane headers -->
|
||||
<rect x="90" y="1390" width="220" height="62" class="soft-card"/>
|
||||
<rect x="385" y="1390" width="245" height="62" class="onprem"/>
|
||||
<rect x="705" y="1390" width="245" height="62" class="security"/>
|
||||
<rect x="1025" y="1390" width="230" height="62" class="security"/>
|
||||
<rect x="1330" y="1390" width="240" height="62" class="cloud"/>
|
||||
<rect x="1640" y="1390" width="185" height="62" class="database"/>
|
||||
<text x="200" y="1428" class="box-title" text-anchor="middle">Browser</text>
|
||||
<text x="507" y="1428" class="box-title" text-anchor="middle">On-prem MVC</text>
|
||||
<text x="827" y="1428" class="box-title" text-anchor="middle">Cloud client</text>
|
||||
<text x="1140" y="1428" class="box-title" text-anchor="middle">OAuth service</text>
|
||||
<text x="1450" y="1428" class="box-title" text-anchor="middle">Identity service</text>
|
||||
<text x="1732" y="1428" class="box-title" text-anchor="middle">Redis / DB</text>
|
||||
|
||||
<line x1="200" y1="1452" x2="200" y2="2365" class="lane"/>
|
||||
<line x1="507" y1="1452" x2="507" y2="2365" class="lane"/>
|
||||
<line x1="827" y1="1452" x2="827" y2="2365" class="lane"/>
|
||||
<line x1="1140" y1="1452" x2="1140" y2="2365" class="lane"/>
|
||||
<line x1="1450" y1="1452" x2="1450" y2="2365" class="lane"/>
|
||||
<line x1="1732" y1="1452" x2="1732" y2="2365" class="lane"/>
|
||||
|
||||
<!-- Sequence messages -->
|
||||
<circle cx="95" cy="1500" r="15" class="step-circle"/><text x="95" y="1500" class="step-number">1</text>
|
||||
<path d="M200 1500 H497" class="line-green"/>
|
||||
<text x="348" y="1483" class="small bold" text-anchor="middle">POST login ID + password</text>
|
||||
<text x="348" y="1523" class="tiny muted" text-anchor="middle">/ver/authenticatelogin</text>
|
||||
|
||||
<circle cx="95" cy="1575" r="15" class="step-circle"/><text x="95" y="1575" class="step-number">2</text>
|
||||
<path d="M507 1575 H817" class="line-green"/>
|
||||
<text x="662" y="1558" class="small bold" text-anchor="middle">CloudAuthenticationGateway.authenticate()</text>
|
||||
<text x="662" y="1598" class="tiny muted" text-anchor="middle">Synchronous MVC → reactive client boundary</text>
|
||||
|
||||
<circle cx="95" cy="1650" r="15" class="step-circle"/><text x="95" y="1650" class="step-number">3</text>
|
||||
<path d="M827 1650 H1130" class="line-orange"/>
|
||||
<text x="978" y="1633" class="small bold" text-anchor="middle">POST machine assertion</text>
|
||||
<text x="978" y="1673" class="tiny muted" text-anchor="middle">Only when cached access token is absent/expiring</text>
|
||||
|
||||
<circle cx="95" cy="1725" r="15" class="step-circle"/><text x="95" y="1725" class="step-number">4</text>
|
||||
<path d="M1140 1725 H1440" class="line-orange"/>
|
||||
<text x="1290" y="1708" class="small bold" text-anchor="middle">Decrypt JWE + verify signed inner JWT</text>
|
||||
<text x="1290" y="1748" class="tiny muted" text-anchor="middle">Validate client ID, installation, audience and expiry</text>
|
||||
|
||||
<circle cx="95" cy="1800" r="15" class="step-circle"/><text x="95" y="1800" class="step-number">5</text>
|
||||
<path d="M1140 1800 H837" class="line-orange"/>
|
||||
<text x="988" y="1783" class="small bold" text-anchor="middle">Return short-lived RS256 access JWT</text>
|
||||
<text x="988" y="1823" class="tiny muted" text-anchor="middle">Claims: client_id, installation_id, scope, issuer, audience, expiry</text>
|
||||
|
||||
<circle cx="95" cy="1885" r="15" class="step-circle"/><text x="95" y="1885" class="step-number">6</text>
|
||||
<path d="M827 1885 H1440" class="line-blue"/>
|
||||
<text x="1133" y="1868" class="small bold" text-anchor="middle">Bearer JWT + encrypted login envelope</text>
|
||||
<text x="1133" y="1908" class="tiny muted" text-anchor="middle">AES-256-GCM payload; AES key wrapped by RSA-OAEP-256</text>
|
||||
|
||||
<circle cx="95" cy="1970" r="15" class="step-circle"/><text x="95" y="1970" class="step-number">7</text>
|
||||
<path d="M1450 1970 H1722" class="line-blue"/>
|
||||
<text x="1586" y="1953" class="small bold" text-anchor="middle">Atomically claim nonce in Redis</text>
|
||||
<text x="1586" y="1993" class="tiny muted" text-anchor="middle">Reject duplicate; apply short TTL</text>
|
||||
|
||||
<circle cx="95" cy="2045" r="15" class="step-circle"/><text x="95" y="2045" class="step-number">8</text>
|
||||
<path d="M1450 2045 H1722" class="line-blue"/>
|
||||
<text x="1586" y="2028" class="small bold" text-anchor="middle">Vert.x prepared SQL queries</text>
|
||||
<text x="1586" y="2068" class="tiny muted" text-anchor="middle">User → password check → menu → login audit</text>
|
||||
|
||||
<circle cx="95" cy="2120" r="15" class="step-circle"/><text x="95" y="2120" class="step-number">9</text>
|
||||
<path d="M1450 2120 H837" class="line-blue"/>
|
||||
<text x="1143" y="2103" class="small bold" text-anchor="middle">Authenticated identity + structured menu</text>
|
||||
<text x="1143" y="2143" class="tiny muted" text-anchor="middle">User, group, company, branch, permissions and login time</text>
|
||||
|
||||
<circle cx="95" cy="2195" r="15" class="step-circle"/><text x="95" y="2195" class="step-number">10</text>
|
||||
<path d="M827 2195 H517" class="line-green"/>
|
||||
<text x="672" y="2178" class="small bold" text-anchor="middle">Map cloud response to legacy SessionValues</text>
|
||||
<text x="672" y="2218" class="tiny muted" text-anchor="middle">Render menu HTML and preserve existing JSP expectations</text>
|
||||
|
||||
<circle cx="95" cy="2270" r="15" class="step-circle"/><text x="95" y="2270" class="step-number">11</text>
|
||||
<path d="M507 2270 H210" class="line-green"/>
|
||||
<text x="358" y="2253" class="small bold" text-anchor="middle">Store HTTP session and return home</text>
|
||||
<text x="358" y="2293" class="tiny muted" text-anchor="middle">Existing authorization and UI flow continues</text>
|
||||
|
||||
<rect x="118" y="2330" width="1684" height="52" class="security"/>
|
||||
<text x="960" y="2362" class="small bold" text-anchor="middle">Cloud identity is mandatory: there is no local password fallback. A cloud outage returns a controlled temporary-unavailable login message.</text>
|
||||
|
||||
<!-- Security model -->
|
||||
<text x="70" y="2477" class="section-title">4 · Security model</text>
|
||||
<text x="70" y="2507" class="section-sub">Machine identity, transport security, application-layer encryption and replay protection are separate layers.</text>
|
||||
<rect x="70" y="2537" width="1780" height="410" class="card"/>
|
||||
|
||||
<rect x="95" y="2570" width="405" height="330" class="security"/>
|
||||
<text x="125" y="2610" class="box-title orange">A · Machine assertion</text>
|
||||
<text x="125" y="2645" class="small">1. Inner JWT signed by installation private key</text>
|
||||
<text x="125" y="2674" class="small">2. Identifies client + installation</text>
|
||||
<text x="125" y="2703" class="small">3. Nested inside RSA-OAEP-256 JWE</text>
|
||||
<text x="125" y="2732" class="small">4. Cloud decrypts with assertion private key</text>
|
||||
<text x="125" y="2761" class="small">5. Signature checked using registered client key</text>
|
||||
<text x="125" y="2790" class="small">6. Assertion may be long-lived and is used only</text>
|
||||
<text x="143" y="2816" class="small">to obtain short-lived access tokens</text>
|
||||
<rect x="125" y="2845" width="335" height="34" class="chip"/>
|
||||
<text x="292" y="2868" class="tiny bold" text-anchor="middle">Customer-specific asymmetric identity</text>
|
||||
|
||||
<rect x="530" y="2570" width="405" height="330" class="security"/>
|
||||
<text x="560" y="2610" class="box-title orange">B · Access token</text>
|
||||
<text x="560" y="2645" class="small">• RS256 signed by the cloud</text>
|
||||
<text x="560" y="2674" class="small">• Short expiry; cached until refresh skew</text>
|
||||
<text x="560" y="2703" class="small">• Bound to client ID and installation ID</text>
|
||||
<text x="560" y="2732" class="small">• Carries identity.login scope</text>
|
||||
<text x="560" y="2761" class="small">• Issuer and audience validated</text>
|
||||
<text x="560" y="2790" class="small">• Required by Spring Security before the</text>
|
||||
<text x="578" y="2816" class="small">cloud identity controller executes</text>
|
||||
<rect x="560" y="2845" width="335" height="34" class="chip"/>
|
||||
<text x="727" y="2868" class="tiny bold" text-anchor="middle">Machine authorization, not user session</text>
|
||||
|
||||
<rect x="965" y="2570" width="405" height="330" class="security"/>
|
||||
<text x="995" y="2610" class="box-title orange">C · Login envelope</text>
|
||||
<text x="995" y="2645" class="small">• Fresh random AES-256 key per request</text>
|
||||
<text x="995" y="2674" class="small">• Credentials encrypted with AES-GCM</text>
|
||||
<text x="995" y="2703" class="small">• AES key encrypted by cloud RSA key</text>
|
||||
<text x="995" y="2732" class="small">• GCM detects ciphertext tampering</text>
|
||||
<text x="995" y="2761" class="small">• Payload contains nonce and timestamp</text>
|
||||
<text x="995" y="2790" class="small">• Client/installation values must match</text>
|
||||
<text x="1013" y="2816" class="small">the authenticated access-token claims</text>
|
||||
<rect x="995" y="2845" width="335" height="34" class="chip"/>
|
||||
<text x="1162" y="2868" class="tiny bold" text-anchor="middle">Application encryption in addition to TLS</text>
|
||||
|
||||
<rect x="1400" y="2570" width="405" height="330" class="security"/>
|
||||
<text x="1430" y="2610" class="box-title orange">D · Replay and transport</text>
|
||||
<text x="1430" y="2645" class="small">• Redis SET-if-absent claims each nonce</text>
|
||||
<text x="1430" y="2674" class="small">• Duplicate nonce is rejected</text>
|
||||
<text x="1430" y="2703" class="small">• Timestamp freshness window enforced</text>
|
||||
<text x="1430" y="2732" class="small">• HTTPS is mandatory outside local dev</text>
|
||||
<text x="1430" y="2761" class="small">• Credentials and tokens are not logged</text>
|
||||
<text x="1430" y="2790" class="small">• Private keys and assertions require</text>
|
||||
<text x="1448" y="2816" class="small">owner-only storage and rotation controls</text>
|
||||
<rect x="1430" y="2845" width="335" height="34" class="chip"/>
|
||||
<text x="1597" y="2868" class="tiny bold" text-anchor="middle">Captured requests cannot be reused</text>
|
||||
|
||||
<!-- Cloud login internals -->
|
||||
<text x="70" y="3017" class="section-title">5 · Cloud login internals and session compatibility</text>
|
||||
<text x="70" y="3047" class="section-sub">Prepared SQL retrieves only the identity data needed to preserve the existing SessionValues contract.</text>
|
||||
<rect x="70" y="3077" width="1095" height="500" class="card"/>
|
||||
<text x="105" y="3120" class="box-title blue">Cloud identity processing</text>
|
||||
|
||||
<circle cx="125" cy="3170" r="14" class="step-circle"/><text x="125" y="3170" class="step-number">1</text>
|
||||
<text x="155" y="3176" class="body">Decrypt payload and validate machine binding, timestamp and nonce.</text>
|
||||
<circle cx="125" cy="3225" r="14" class="step-circle"/><text x="125" y="3225" class="step-number">2</text>
|
||||
<text x="155" y="3231" class="body">Load active user by case-insensitive login ID from identity.app_user.</text>
|
||||
<circle cx="125" cy="3280" r="14" class="step-circle"/><text x="125" y="3280" class="step-number">3</text>
|
||||
<text x="155" y="3286" class="body">Verify password using the compatible legacy password verifier.</text>
|
||||
<circle cx="125" cy="3335" r="14" class="step-circle"/><text x="125" y="3335" class="step-number">4</text>
|
||||
<text x="155" y="3341" class="body">Load group, company and branch details.</text>
|
||||
<circle cx="125" cy="3390" r="14" class="step-circle"/><text x="125" y="3390" class="step-number">5</text>
|
||||
<text x="155" y="3396" class="body">Load visible permitted pages and exclude explicitly denied pages.</text>
|
||||
<circle cx="125" cy="3445" r="14" class="step-circle"/><text x="125" y="3445" class="step-number">6</text>
|
||||
<text x="155" y="3451" class="body">Insert cloud login audit row and return structured identity/menu response.</text>
|
||||
<rect x="105" y="3490" width="1025" height="58" class="database"/>
|
||||
<text x="617" y="3514" class="small bold" text-anchor="middle">Vert.x prepared queries keep values separate from SQL text</text>
|
||||
<text x="617" y="3538" class="tiny muted" text-anchor="middle">Tables: identity.app_user · user_group · company · company_branch · permission · pages · denied_pages · user_loginhistory</text>
|
||||
|
||||
<rect x="1195" y="3077" width="655" height="500" class="card"/>
|
||||
<text x="1230" y="3120" class="box-title green">Legacy compatibility mapping</text>
|
||||
<text x="1230" y="3158" class="small bold">CloudSessionMapper populates:</text>
|
||||
<text x="1250" y="3192" class="small">• User ID, login ID and display name</text>
|
||||
<text x="1250" y="3222" class="small">• Group ID and group name</text>
|
||||
<text x="1250" y="3252" class="small">• Company ID, name and code</text>
|
||||
<text x="1250" y="3282" class="small">• Branch ID, name, code and location</text>
|
||||
<text x="1250" y="3312" class="small">• Login timestamp in the legacy format</text>
|
||||
<text x="1250" y="3342" class="small">• Menu HTML rendered from cloud menu records</text>
|
||||
<rect x="1230" y="3380" width="585" height="116" class="onprem"/>
|
||||
<text x="1522" y="3414" class="small bold" text-anchor="middle">No local password query or fallback remains</text>
|
||||
<text x="1522" y="3443" class="small" text-anchor="middle">Existing JSP controllers keep reading SessionValues</text>
|
||||
<text x="1522" y="3472" class="small" text-anchor="middle">Operational authorization continues without UI rewrites</text>
|
||||
<text x="1230" y="3535" class="tiny muted">Logout currently invalidates the local HTTP session. A cloud logout/audit API is the future option if logout timestamps are required.</text>
|
||||
|
||||
<!-- Code navigation and failures -->
|
||||
<text x="70" y="3647" class="section-title">6 · Code navigation</text>
|
||||
<text x="70" y="3677" class="section-sub">Primary implementation points: SessionController → CloudAuthenticationGateway → OAuthMachineTokenProvider / LoginEnvelopeEncryptor → CloudLoginController → IdentityAuthenticationService → IdentityRepository → CloudSessionMapper.</text>
|
||||
|
||||
<!-- Footer strip overlays final area intentionally within document bounds -->
|
||||
<rect x="50" y="3695" width="1820" height="1" fill="#9eb5c5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 26 KiB |
BIN
Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx
Normal file
BIN
Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
eyJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAtMjU2In0.rf8bViJPb5bRJHFamVoCftE0TLA1Gz7JyGnfcjtKEx3KiUL4SSbgYSUQlsKhCt8VJMWE--UpgbkM0Bv3pFVsMF_uh693MhFstEy_A6S3MODjoUV_bMLa_Zr1yH2Jwzud4LSyw-ctJ7G-vO8S_I6YML0HV5kNbobdFmMfXFLGKssVmM_Km0m57ZgSjj3zRii0cKpmdwwSbWoJC2y519TpKhIja0OZAoN1oB3IVKvERlFYCQYUpQmcRkzULGrlSoLIm9iqbRX4qMGhGISwVS6JdVtn9WjpSjF3xkUxN_QsVuYKYFL_kEKWSNepIcfoip7Ag9Qw_S1RZA_KO908MgrzGRbs2UIpxY9sAM8yZ-k1NTk0swV836s5pdDIP3PHovcd6iR8mqWBPt7mq7ES98vNeCZxqPLB_b1JuNfKsbLPdRIvEk2DTjZHeEi-yA1yl5uMi_1f4la6mgy65u_jLL_Ow28uZtj-8yQ3dR4lF3K7tkxQaB4LQiH5nl6lXEndpoPe.Cpu6VoHf2ptCue_L.uH-Pq1WZN-x_c71Iyx8X1QJa1JZRp9sYta9iUOmQ4W8x4Qh2wYm-_tpr8FEw56MLvIb8IH_gHVVLAJgXTmCZhyo26vJlPCtnMMPYFDrPRzTJv9Ewg7RMpsAh_kXFFK4UJ6Tgt_r7Tg_bnVOTKXodUjl3wVdMowZRX_ua-gKwLU-2LDWdqIRmuFDSOkMb1sFBZLPyjWHg_rThs-RwVa4YJdDi74s-L0WX0xRPXzeovu1-xvOgZ0xYf0ScbsFQgvK-JwAxOWsB1WKeHRqnnx5-apK6qtzhpiJ30c7ukPlrg2nieuJvkXOVuKBKXLVRLSViOH4HU76DD96btDUA0Cg2kEURpepucX0VWPyB7K3xb9v1AT42TUkW9wZzdmIs9saRo-ItuWzlCezvapLIsbqmYCs_rW2J3KgqXrUyy2mptn32sqaqQZnlNVu4wwboPCCrbl2MG5CYlYKrwaOHNg611j8sCDkIptm_UmPFnOnzvQYwURuDSgzswTIV5bTd_58yxag2yWwiMdGLMDbxxSUPQ6a4EbuDkqwmGBTSLiG7SaSFchSg6o9EflqK6k-u_rlixCxmfg1A6sa7nloW17dOxDzGKtmn9lBoaskL97nNrhP6ptftak7j4TIZulz1FUOSVoR8kXK8RNQMj-b6rg_MdSO9Ecq5hZsza1SzjkzKZvV-tpP6Wl6-rszCH6g3DIQAWN5aWdUkwuya1HqDDyVPbUOVQPsp4nhVC24QmnlbcqFwQdoCrikYKyX3Snc0H6_K4z9c6nURAdj6THwHmA9BfHtJniI_rEIvkmBk3yndi-H5eAjtxIoYSFQrSTWEFhgvk6bAKoR_xb_cHgzKOxYLBiO_FifkjkFDOlkVBuYnZEp3oRePDKgMDfL5Oxjuie4u-8yrH52Cd32yQzbvGplaXLLeEDf3i71dcz4LkO5DknScWG3G_SbKBpjlse-GKe3XYqRXeRIYLpyiX1MXph0b-Q.dvHU75ZR8wJFxoSacfkthw
|
||||
40
config/clients/matrix/matrix-signing-private.pem
Normal file
40
config/clients/matrix/matrix-signing-private.pem
Normal file
@@ -0,0 +1,40 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
M/D4ZjXsfrW//BMF6+w2Bh4=
|
||||
-----END PRIVATE KEY-----
|
||||
11
config/clients/matrix/matrix-signing-public.pem
Normal file
11
config/clients/matrix/matrix-signing-public.pem
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAloJtGMOLcTNWe1IgdrT8
|
||||
uYucI1Vs02WjyUPZ7Po51iC67QmGdG0InzlBJYdkYtTz8eda+qArZMNGB0ChFs/7
|
||||
xKPKASkMvVcrm4IYbth0DEIkXWAL7YXsWd8qdS+KsnxuC6TqvuA9Xq/PX4RmtAq4
|
||||
bl0/1T2HZjJs53/b73l5NehcseZU129w+5lBqDwW5xh+1VnXNbc96za7BFfQSnfg
|
||||
9WwaWSpo8Y57XATnV7s46yT8gNKM29INHJXs4aIrQ9tcLnTbDz9nhGceNgec3AhO
|
||||
zc4TyCPugUiG9WD4ehsQr+7Cp30RqT5l9ZPvoBTpAdHLZ1r/jB+qY6+WkY6EsOJW
|
||||
2xg9bUYoHXjMiFdi8OLXC4NTAPUquFOaIMfjxGaYQc8NoDMFiq3qdTWi2ndCOoxX
|
||||
yFxYRP5pYs/oA46UAHZsCHaspjvl9jfR/abCALBtsFLwpLA9qqBws5ojXFH//UoX
|
||||
E7dXnJziCFQNyJOhXtIhbQhtb3wJc6WSLtDrRpFtaJudAgMBAAE=
|
||||
-----END PUBLIC KEY-----
|
||||
40
config/keys/access-token-private.pem
Normal file
40
config/keys/access-token-private.pem
Normal file
@@ -0,0 +1,40 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
m/lDtrY4EpMy9FSQD1zb+qE=
|
||||
-----END PRIVATE KEY-----
|
||||
11
config/keys/access-token-public.pem
Normal file
11
config/keys/access-token-public.pem
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAvyfuM67QxEonEvC2Vf21
|
||||
P+fxo406Z4C+zNzxH/8tCPhMH94Sklt10WXqSfQFfZbhJPVYoZL8m0NDI7ozI7lI
|
||||
MPAhAVfcFOzO6VqpFGrzMgMqmnWLrxRsL3y1rjB3LnE84W+ATcS/mlk6rwbCfrSo
|
||||
RRlY3i79ol8h36jhhaHT2/iGGO3uvqAQK334lQEOlqr+YL4Kkr/GMOm86bE4PcZS
|
||||
JgCTAnlCQC7TUu6jGWuocmI1nNwLXPjQvXgc+HKtJEqFGf+a0fty9fLgaR2yIzlM
|
||||
9i31r3kjeKzqVe2DeLTbttd8bVWU4/KmGg8rEchtwC8wR4inuDUjwJ3FwmdBrvDp
|
||||
Ga1AM8yofdbs73Lv2EgkvRLvlgaWiX2P6KdlKGHeaDj9v0YWJbiFM4rc9y5hD3lG
|
||||
2unhpvP/49OFXVBT2/pm86LmxSlwsun3G2LKNOXI646jhdVHQkIVcBiHTHYQOe9/
|
||||
xLqx3ZiEM7witbzuJnxIkbj/8OFgH+WDUmTQVXtbz7vdAgMBAAE=
|
||||
-----END PUBLIC KEY-----
|
||||
40
config/keys/assertion-decryption-private.pem
Normal file
40
config/keys/assertion-decryption-private.pem
Normal file
@@ -0,0 +1,40 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
RqsZZ1cLu0TCOoNBkcSr79a6
|
||||
-----END PRIVATE KEY-----
|
||||
11
config/keys/assertion-decryption-public.pem
Normal file
11
config/keys/assertion-decryption-public.pem
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAtZc750+cxwDcG/lvpEHm
|
||||
3fxFgp5X5df90s51R4ShKpV6dmDRYXnVil9GmXrJf/aGAqrsf6ZUvZWJrAJ2OO2X
|
||||
Wt8tcqajnaWdM33BrH/QztTJkoGHZM1xLY5EpRaWo1LaG2gNGsaxG0ON9T1emKi7
|
||||
XDEcI9Lmc/mhP6SuuUnvGwq1Z433vqGodorkU+T2jCfflhwfenvBcOk4GJZoJFlY
|
||||
sjy9Dtlha70MJpdVFWffxs4UBhhkjckb7GbDTVxa3xvz1NEbkY2+oh/tJmuIuupA
|
||||
7A6TnfYP1YPXPMRQFtSPaKMOeKW5J5IorvgQKr4C74HR9iBojJN2oJ6/BjfAaz7P
|
||||
8kywLj2uo9gJemOm1wz8HZyZsz482GHa0wqpUcFfOIr0oTMiJtvLn/FSYVb5xvNg
|
||||
3xWiGPMju32Z49XTX7VxTDsIjGx5wtDd4G8ILS3yUlKw0yufHacmy8Fn+Plk+Xtz
|
||||
Bbz6rq4frV93/3mfVMPGgBDx8mLjVJovPBnXKBSX+SdbAgMBAAE=
|
||||
-----END PUBLIC KEY-----
|
||||
40
config/keys/case-save-private.pem
Normal file
40
config/keys/case-save-private.pem
Normal file
@@ -0,0 +1,40 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIG/gIBADANBgkqhkiG9w0BAQEFAASCBugwggbkAgEAAoIBgQDOk7AQIpVbQp1n
|
||||
CVUm1bu2SqprXBjUn4eG4V/+RuOYm/x/AVwaP67LLazlxfOByQ0fPcK/gqLxWdBq
|
||||
RufzcpFM0i4y9iDCdo1jfkq9UpTlHwo8nvBXzIRTKFHKO1q5pj1NpVQfQxK0PeLN
|
||||
xns996Ncm/FG0uND1Jf8r6OgezeaRbJYM1ib27p5wsGg/FboGkBeJQ5adKlzQg7a
|
||||
eMZu+dgjvOzm1h0WNW0NN/hM5IgNd4Jdzuc1SF//JBUAQq8aavrhKKFlz7XVPkBc
|
||||
nLV5USMXCPuFapxnGGC6Rpw6hTyhQ5E7Q7QfUchLwgJccGAovXsmr5+6sQieVTg9
|
||||
+nW2QDt1iH2ddD3X6GZG1NJcDUGnNZ0jMLd9CqfBOwPpzIgOw91EH2JIlPLAU9sb
|
||||
LAwhuHjAilQ5LezzcMG2nGPaofDvNzlD30E/gN3fySX16N91i4VhGZvOur4FlHuJ
|
||||
zn8szv7yITFganPewUKvqokCUIMri2HpMKXZmPOClM4XemcLUjsCAwEAAQKCAYAn
|
||||
FrkdBH5Ai4VfRtvPAmiHoO9Ia1/jc+BgPGs+oUlVykZn/ejgqqY3mgf6Xo+qQlHy
|
||||
VGxycpTEmJsgURR483fdEnRdfkdKpMYySmZ4FpVIGayFNgoCgxeS6LE5VCvhrzww
|
||||
YhVd6QI0CvXMvD77xc0qq/Nm1Gbeoe60iMOsjURDM+cAW47rxxMtERY0kSsctkLx
|
||||
zY8+vI6H2bsXyfy6aRpmAMzPDTrpxehBtRKN8jxwV+naOFl1sqE/lSNILlYV6KID
|
||||
HDRgnwVMJCtaYCF88MWuaQQ7O2pX/Ba1x42iZtLCX1ABw3N6/PsB/90KtSNp5Duu
|
||||
naM50NpsFDqAnFXrB7rUM7OK3nnBGVDK4KpD+36HdhNSCvhO+Tz+GQnWh46dMx76
|
||||
0yOlSLoIHdDozP4aQXiHnIKdm8gInTOyoQftm3+11ba9WL7n8xOFH5sAl0em44d2
|
||||
NcYx49myiIk/1VI+SZzPRUxwU4pwXL4rMRXzkRuQLQ6KorKqL2d1iH+crpFVCWEC
|
||||
gcEA9Wy8nqS/2Nq7A+ph2B+Kf1o4Dc8njRrIJRwUBeik8zIfbaH5TdGUrjAyFOdO
|
||||
GaL0bLz02hkOh+vq4NpxqcvLZ4L+phPgeDvb8DFFkdYE2jtbAFFOidGIfplja0aL
|
||||
FBiusu3myGD37/7dSQo7VM/TihO6BANbye+SOFBHmTRNjFy7z6JPZOfQKp+yWFHN
|
||||
3DOemAsG4XdVnWfkLqW7aBzsePae7ivMidHffgr9kdxbMPBemovN5ppFXMMLG6VQ
|
||||
xmrxAoHBANd6bE5f3zEvaBAQElUzHGB3sbAi7YZv/Oms2I0J+qQN9/a5N47VWys6
|
||||
WKN6VB4ZHFU4wWPX56eARDONalmrNTOaMkOSOf/jAAstbMd0WjKs8/T0n80QCtD3
|
||||
ti3HTR9Ls7gFcKzDwVMscnbpFtv8wa2tvIkNodrD9faiQ8ubZfcAyuL1suXEMFKb
|
||||
dor8B/R7DNYztgZ31IeWuVPhhb4iSceWtWyRdaUXDaYG0jZcmWi5zFcFLjiFTckB
|
||||
0RRaZwOX6wKBwHmMsxY3LjTuj7TuvirV5DgSrLRaJpKB8yI998S9ZgR0jJA1qk1a
|
||||
QTLL9+HWdR3JURkRtIrX4hR7SUa3qZeYsVLA0/HY4lFqBBG/tV73CkwzHWzY1/b1
|
||||
6Y4Z1d1pgLQhTVSc8rHHJMSeC2aDRMNKctBt2LDoIOuwVVDCodNEzit4OiQplPy6
|
||||
uqSBl0iaq0Ql5KQUwgGkoqhkreRUfK6htJRsQGZhFtojMXcxZkh5REjGo3QTTZSq
|
||||
TzQT/Uph49GBYQKBwQDELaZ7uJeIvUN+FFPMlAsK3Q6+cR5V3pGh4gcKaYJzaHBQ
|
||||
hZLKjP9DHQzkUZRSDsSX5mNT1pcHDiWJ1f90ggae441Nrcz2ZqJ8iq/V693O813s
|
||||
r+bpmOhwFocbqK9TheWq7fEGqfbSFc5k6pQwVy+yQ1I6aVnpxa2jDVqx/dpYhrUw
|
||||
60ckH3lrTDUiFpHbiUhHoK7htqmdhKFYeCP+1lLbzx+AJ/K3CoUXmnA6pBXbngUn
|
||||
WQUa2mrWTbwgTqopQF8CgcEA72nxgVESZclZQPxkAjnYQCPdhwioYFU2s1C2upNR
|
||||
KQ4SQO7f1wjSrjj33THm6gzCpg3Wi9TCUYZoO/UFYHVtg3mUybwYowQTG1i35Xzy
|
||||
TxaJLz3gL+0WrLqnifxSuxsp1AVMHYxbxyXdAawKtTexVJ0Z/L+e771Kyi8yjJtr
|
||||
511v9Nr9GX4wIQb1OpXKynTqcE0P7OLKnr1ATNReM1jEMlaot2TP8sY7uj6CUgsL
|
||||
5PO2cnRk24OsUqAswTBT2qjd
|
||||
-----END PRIVATE KEY-----
|
||||
11
config/keys/case-save-public.pem
Normal file
11
config/keys/case-save-public.pem
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAzpOwECKVW0KdZwlVJtW7
|
||||
tkqqa1wY1J+HhuFf/kbjmJv8fwFcGj+uyy2s5cXzgckNHz3Cv4Ki8VnQakbn83KR
|
||||
TNIuMvYgwnaNY35KvVKU5R8KPJ7wV8yEUyhRyjtauaY9TaVUH0MStD3izcZ7Pfej
|
||||
XJvxRtLjQ9SX/K+joHs3mkWyWDNYm9u6ecLBoPxW6BpAXiUOWnSpc0IO2njGbvnY
|
||||
I7zs5tYdFjVtDTf4TOSIDXeCXc7nNUhf/yQVAEKvGmr64SihZc+11T5AXJy1eVEj
|
||||
Fwj7hWqcZxhgukacOoU8oUORO0O0H1HIS8ICXHBgKL17Jq+furEInlU4Pfp1tkA7
|
||||
dYh9nXQ91+hmRtTSXA1BpzWdIzC3fQqnwTsD6cyIDsPdRB9iSJTywFPbGywMIbh4
|
||||
wIpUOS3s83DBtpxj2qHw7zc5Q99BP4Dd38kl9ejfdYuFYRmbzrq+BZR7ic5/LM7+
|
||||
8iExYGpz3sFCr6qJAlCDK4th6TCl2ZjzgpTOF3pnC1I7AgMBAAE=
|
||||
-----END PUBLIC KEY-----
|
||||
40
config/keys/login-private.pem
Normal file
40
config/keys/login-private.pem
Normal file
@@ -0,0 +1,40 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
VkpSw8QqBfhHSViEh12BQZa2
|
||||
-----END PRIVATE KEY-----
|
||||
11
config/keys/login-public.pem
Normal file
11
config/keys/login-public.pem
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA2g0IbNxsNIZ6fKaGTHTQ
|
||||
pUFfgJVq3x0SZ458xAQDL0l1PZ+lnMtvS3/ZZpbF99bLuqyzUuHwi+1BJ/Nled61
|
||||
oK8RdJKfUPiQUshXfvWT4/sLsYO6H+v2qw2jBv6kx3h+teDiuHMbw6ikUECr5u1k
|
||||
qkjb+ls3aijtWplmN25HRhwFB/l6/0xAhEQNA36EoNb+ufsDR/2aRQYGQ6qegynp
|
||||
8bUt7JDf3hW1y76UCKlKteFfIOkp4zwY0YYq/NewgKlze08LBrX+zy5xr5Vn8nE0
|
||||
ZtWY8vbNC33Sf6g5D1ZlA+HvquplAWS/rPymj+IN79FeXNegmyCxF/ZafsneaUBn
|
||||
GpSgXYbyASpA+OkqUqJcBEeTI4rfRZPiyx3ztaibg+rgOEM/DUV5le2WyujnsQ75
|
||||
IG4SrVu/XTmF08MN8/hD1+ydBVnhVa7bE4WctqgA1T/5BP7ivAB03a1BJHpKFRh/
|
||||
Q/pO5ONIK8lNGG6aImLPmtJijEvOL0VsUdp+oO87VnTFAgMBAAE=
|
||||
-----END PUBLIC KEY-----
|
||||
76
cygnus-cloud-client/README.md
Normal file
76
cygnus-cloud-client/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Cygnus cloud client
|
||||
|
||||
This module is the reusable on-premises gateway client for Cygnus cloud APIs.
|
||||
It obtains a short-lived machine access token, encrypts login credentials with
|
||||
the cloud login public key, and calls the cloud identity API over WebFlux.
|
||||
|
||||
## On-premises identity configuration
|
||||
|
||||
Cloud login is required by the on-premises application. Configure these as
|
||||
environment variables or JVM system properties:
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| `CYGNUS_CLOUD_BASE_URL` | Cloud gateway/API base URL |
|
||||
| `CYGNUS_TOKEN_URL` | OAuth 2.0 machine-token endpoint |
|
||||
| `CYGNUS_CLIENT_ID` | Provisioned customer/client identifier |
|
||||
| `CYGNUS_INSTALLATION_ID` | Unique on-premises installation identifier |
|
||||
| `CYGNUS_CLIENT_ASSERTION` | Provisioned encrypted assertion or `file:/secure/path/assertion.jwt` |
|
||||
| `CYGNUS_LOGIN_KEY_ID` | Cloud login encryption-key identifier |
|
||||
| `CYGNUS_LOGIN_PUBLIC_KEY` | X.509 RSA public key location |
|
||||
| `CYGNUS_CLOUD_REQUEST_TIMEOUT` | Request timeout, for example `PT10S` |
|
||||
Do not store private keys, client assertions, passwords, or production URLs in
|
||||
source control.
|
||||
|
||||
## Provision a machine assertion
|
||||
|
||||
The assertion is a signed JWT nested inside an RSA-OAEP-256/AES-256-GCM JWE.
|
||||
It is valid for one year; the access token obtained with it is short-lived.
|
||||
|
||||
For local development, the repository setup script automates prerequisite
|
||||
checks, the full Maven verification, directory creation, all three cloud key
|
||||
pairs, the installation key pair, database-backed tenant/install registration,
|
||||
an initial license, and the encrypted machine assertion:
|
||||
|
||||
```bash
|
||||
./scripts/setup-local-communication.sh
|
||||
```
|
||||
|
||||
The script interactively asks for the customer name and slug, installation
|
||||
identifier, cloud URL, database connection, license package/type/duration, and
|
||||
whether to run the full verification. Customer and installation identifiers
|
||||
cannot contain spaces. The slug is the stable tenant key and is used for its
|
||||
directory and signing-key filenames.
|
||||
|
||||
The client account, installation public key, allowed scopes, and license are
|
||||
upserted into PostgreSQL (`identity.client_account`,
|
||||
`identity.client_installation`, and `identity.client_license`). The cloud
|
||||
service resolves this registration dynamically through Redis with PostgreSQL
|
||||
fallback, so adding another customer does not require a cloud restart.
|
||||
|
||||
It preserves existing private keys and assertions. Set
|
||||
`CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be
|
||||
regenerated. The manual provisioning commands follow.
|
||||
|
||||
Generate the installation signing key:
|
||||
|
||||
```bash
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
|
||||
-out client-signing-private.pem
|
||||
openssl pkey -in client-signing-private.pem -pubout \
|
||||
-out client-signing-public.pem
|
||||
```
|
||||
|
||||
Generate the assertion after the cloud assertion-encryption public key has
|
||||
been securely delivered:
|
||||
|
||||
```bash
|
||||
mvn -pl cygnus-cloud-client exec:java \
|
||||
-Dexec.mainClass=com.cygnus.client.provisioning.MachineAssertionGenerator \
|
||||
-Dexec.args="customer-a site-01 https://cloud.example.com/oauth2/token \
|
||||
client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt"
|
||||
```
|
||||
|
||||
The setup script stores `client-signing-public.pem` in the installation record
|
||||
used by the cloud. Keep the private key and generated assertion only on the
|
||||
on-premises server with owner-only filesystem permissions.
|
||||
57
cygnus-cloud-client/pom.xml
Normal file
57
cygnus-cloud-client/pom.xml
Normal file
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.cygnus</groupId>
|
||||
<artifactId>cygnus-parent</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>cygnus-cloud-client</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Cygnus Cloud Client</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-webflux</artifactId>
|
||||
<version>${spring.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor.netty</groupId>
|
||||
<artifactId>reactor-netty-http</artifactId>
|
||||
<version>${reactor.netty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
<version>${nimbus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cygnus.client;
|
||||
|
||||
import com.cygnus.client.security.LoginEnvelopeEncryptor;
|
||||
import com.cygnus.client.security.MachineTokenProvider;
|
||||
import com.cygnus.client.security.OAuthMachineTokenProvider;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.Clock;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
public final class CloudClientFactory {
|
||||
|
||||
private CloudClientFactory() {
|
||||
}
|
||||
|
||||
public static CloudIdentityClient create(
|
||||
CloudClientProperties properties, ObjectMapper objectMapper, Clock clock) {
|
||||
WebClient webClient = WebClient.builder().build();
|
||||
MachineTokenProvider tokenProvider =
|
||||
new OAuthMachineTokenProvider(webClient, properties, clock);
|
||||
LoginEnvelopeEncryptor encryptor =
|
||||
new LoginEnvelopeEncryptor(properties, objectMapper);
|
||||
return new CloudIdentityClient(
|
||||
webClient, tokenProvider, encryptor, properties, clock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.cygnus.client;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public record CloudClientProperties(
|
||||
URI baseUri,
|
||||
URI tokenUri,
|
||||
String clientId,
|
||||
String installationId,
|
||||
String clientAssertion,
|
||||
String loginKeyId,
|
||||
String loginPublicKeyLocation,
|
||||
Duration requestTimeout,
|
||||
Duration tokenRefreshSkew) {
|
||||
|
||||
public static CloudClientProperties fromSystem() {
|
||||
CloudClientProperties properties = new CloudClientProperties(
|
||||
URI.create(value("CYGNUS_CLOUD_BASE_URL", "http://localhost:8090")),
|
||||
URI.create(value(
|
||||
"CYGNUS_TOKEN_URL",
|
||||
"http://localhost:8090/oauth2/token")),
|
||||
value("CYGNUS_CLIENT_ID", ""),
|
||||
value("CYGNUS_INSTALLATION_ID", ""),
|
||||
value("CYGNUS_CLIENT_ASSERTION", ""),
|
||||
value("CYGNUS_LOGIN_KEY_ID", "cygnus-login-2026-01"),
|
||||
value(
|
||||
"CYGNUS_LOGIN_PUBLIC_KEY",
|
||||
"file:./config/keys/login-public.pem"),
|
||||
Duration.parse(value("CYGNUS_CLOUD_REQUEST_TIMEOUT", "PT10S")),
|
||||
Duration.parse(value("CYGNUS_TOKEN_REFRESH_SKEW", "PT30S")));
|
||||
properties.validate();
|
||||
return properties;
|
||||
}
|
||||
|
||||
public void validate() {
|
||||
List<String> missing = new ArrayList<>();
|
||||
require(clientId, "CYGNUS_CLIENT_ID", missing);
|
||||
require(installationId, "CYGNUS_INSTALLATION_ID", missing);
|
||||
require(clientAssertion, "CYGNUS_CLIENT_ASSERTION", missing);
|
||||
require(loginKeyId, "CYGNUS_LOGIN_KEY_ID", missing);
|
||||
require(loginPublicKeyLocation, "CYGNUS_LOGIN_PUBLIC_KEY", missing);
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"Cloud identity requires these settings: "
|
||||
+ String.join(", ", missing));
|
||||
}
|
||||
if (requestTimeout.isZero() || requestTimeout.isNegative()) {
|
||||
throw new IllegalStateException(
|
||||
"CYGNUS_CLOUD_REQUEST_TIMEOUT must be greater than zero");
|
||||
}
|
||||
}
|
||||
|
||||
private static void require(String value, String name, List<String> missing) {
|
||||
if (value == null || value.isBlank()) {
|
||||
missing.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static String value(String name, String defaultValue) {
|
||||
String systemValue = System.getProperty(name);
|
||||
if (systemValue != null) {
|
||||
return systemValue;
|
||||
}
|
||||
String environmentValue = System.getenv(name);
|
||||
return environmentValue == null ? defaultValue : environmentValue;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.cygnus.client;
|
||||
|
||||
import com.cygnus.client.model.CloudIdentitySession;
|
||||
import com.cygnus.client.model.CloudDataItem;
|
||||
import com.cygnus.client.model.CloudDataRequest;
|
||||
import com.cygnus.client.model.CloudQueryResponse;
|
||||
import com.cygnus.client.model.LoginPayload;
|
||||
import com.cygnus.client.security.LoginEnvelopeEncryptor;
|
||||
import com.cygnus.client.security.MachineTokenProvider;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class CloudIdentityClient {
|
||||
|
||||
private static final ParameterizedTypeReference<List<CloudDataItem>> CLOUD_DATA_LIST =
|
||||
new ParameterizedTypeReference<>() { };
|
||||
|
||||
private final WebClient webClient;
|
||||
private final MachineTokenProvider tokenProvider;
|
||||
private final LoginEnvelopeEncryptor encryptor;
|
||||
private final CloudClientProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
public CloudIdentityClient(
|
||||
WebClient webClient,
|
||||
MachineTokenProvider tokenProvider,
|
||||
LoginEnvelopeEncryptor encryptor,
|
||||
CloudClientProperties properties,
|
||||
Clock clock) {
|
||||
this.webClient = webClient;
|
||||
this.tokenProvider = tokenProvider;
|
||||
this.encryptor = encryptor;
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Mono<CloudIdentitySession> authenticate(String loginId, String password) {
|
||||
LoginPayload payload = new LoginPayload(
|
||||
loginId,
|
||||
password,
|
||||
properties.clientId(),
|
||||
properties.installationId(),
|
||||
UUID.randomUUID().toString(),
|
||||
clock.instant());
|
||||
return tokenProvider.accessToken()
|
||||
.flatMap(token -> webClient.post()
|
||||
.uri(properties.baseUri().resolve("/api/v1/identity/login"))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(encryptor.encrypt(payload))
|
||||
.retrieve()
|
||||
.bodyToMono(CloudIdentitySession.class))
|
||||
.timeout(properties.requestTimeout());
|
||||
}
|
||||
|
||||
public Mono<CloudQueryResponse> fetchQuery(int queryId) {
|
||||
return tokenProvider.accessToken()
|
||||
.flatMap(token -> webClient.get()
|
||||
.uri(properties.baseUri().resolve("/api/v1/queries/" + queryId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.retrieve()
|
||||
.bodyToMono(CloudQueryResponse.class))
|
||||
.timeout(properties.requestTimeout());
|
||||
}
|
||||
|
||||
public Mono<CloudQueryResponse> fetchQuery(String queryKey) {
|
||||
return tokenProvider.accessToken()
|
||||
.flatMap(token -> webClient.get()
|
||||
.uri(properties.baseUri().resolve("/api/v1/queries/key/" + queryKey))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.retrieve()
|
||||
.bodyToMono(CloudQueryResponse.class))
|
||||
.timeout(properties.requestTimeout());
|
||||
}
|
||||
|
||||
public Mono<List<CloudDataItem>> fetchData(String scope, Map<String, Object> data) {
|
||||
return tokenProvider.accessToken()
|
||||
.flatMap(token -> webClient.post()
|
||||
.uri(properties.baseUri().resolve("/api/v1/platform/data"))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(new CloudDataRequest(scope, Map.copyOf(data)))
|
||||
.retrieve()
|
||||
.bodyToMono(CLOUD_DATA_LIST))
|
||||
.timeout(properties.requestTimeout());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record CloudDataItem(Object value, String label, String group, Map<String, Object> data) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record CloudDataRequest(String scope, Map<String, Object> data) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record CloudIdentitySession(
|
||||
short userId,
|
||||
String loginId,
|
||||
String displayName,
|
||||
short groupId,
|
||||
String groupName,
|
||||
short branchId,
|
||||
String branchName,
|
||||
String branchCode,
|
||||
String branchLocation,
|
||||
short companyId,
|
||||
String companyName,
|
||||
String companyCode,
|
||||
Instant loginTime,
|
||||
List<CloudMenuItem> menu) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
public record CloudMenuItem(
|
||||
short pageId,
|
||||
String label,
|
||||
String targetUrl,
|
||||
short parentPage,
|
||||
short pageOrder,
|
||||
String permission,
|
||||
String targetWindow,
|
||||
String requestValue) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
public record CloudQueryResponse(int queryId, String query) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
public record EncryptedLoginRequest(
|
||||
String keyId,
|
||||
String encryptedKey,
|
||||
String initializationVector,
|
||||
String encryptedPayload) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginPayload(
|
||||
String loginId,
|
||||
String password,
|
||||
String clientId,
|
||||
String installationId,
|
||||
String nonce,
|
||||
Instant issuedAt) {
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.cygnus.client.provisioning;
|
||||
|
||||
import com.nimbusds.jose.EncryptionMethod;
|
||||
import com.nimbusds.jose.JWEAlgorithm;
|
||||
import com.nimbusds.jose.JWEHeader;
|
||||
import com.nimbusds.jose.JWEObject;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.Payload;
|
||||
import com.nimbusds.jose.crypto.RSAEncrypter;
|
||||
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
|
||||
public final class MachineAssertionGenerator {
|
||||
|
||||
private MachineAssertionGenerator() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length != 6) {
|
||||
throw new IllegalArgumentException(
|
||||
"Expected: <client-id> <installation-id> <token-audience> "
|
||||
+ "<client-signing-private.pem> "
|
||||
+ "<cloud-assertion-public.pem> <output-file>");
|
||||
}
|
||||
Instant issuedAt = Instant.now();
|
||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||
.issuer(args[0])
|
||||
.subject(args[0])
|
||||
.audience(args[2])
|
||||
.issueTime(Date.from(issuedAt))
|
||||
.expirationTime(Date.from(issuedAt.plus(365, ChronoUnit.DAYS)))
|
||||
.claim("installation_id", args[1])
|
||||
.build();
|
||||
SignedJWT signed = new SignedJWT(
|
||||
new JWSHeader(JWSAlgorithm.RS256), claims);
|
||||
signed.sign(new RSASSASigner(privateKey(Path.of(args[3]))));
|
||||
|
||||
JWEObject encrypted = new JWEObject(
|
||||
new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM),
|
||||
new Payload(signed.serialize()));
|
||||
encrypted.encrypt(new RSAEncrypter(publicKey(Path.of(args[4]))));
|
||||
|
||||
Path output = Path.of(args[5]).toAbsolutePath();
|
||||
Files.writeString(
|
||||
output,
|
||||
encrypted.serialize(),
|
||||
StandardCharsets.US_ASCII,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
output,
|
||||
Set.of(
|
||||
java.nio.file.attribute.PosixFilePermission.OWNER_READ,
|
||||
java.nio.file.attribute.PosixFilePermission.OWNER_WRITE));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// Non-POSIX platforms must secure this file through their native ACL.
|
||||
}
|
||||
System.out.println("Machine assertion written to " + output);
|
||||
}
|
||||
|
||||
private static RSAPrivateKey privateKey(Path path) throws Exception {
|
||||
String encoded = pem(path, "PRIVATE KEY");
|
||||
return (RSAPrivateKey) KeyFactory.getInstance("RSA")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
}
|
||||
|
||||
private static RSAPublicKey publicKey(Path path) throws Exception {
|
||||
String encoded = pem(path, "PUBLIC KEY");
|
||||
return (RSAPublicKey) KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
}
|
||||
|
||||
private static String pem(Path path, String type) throws Exception {
|
||||
return Files.readString(path, StandardCharsets.US_ASCII)
|
||||
.replace("-----BEGIN " + type + "-----", "")
|
||||
.replace("-----END " + type + "-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.client.security;
|
||||
|
||||
public class CloudClientException extends RuntimeException {
|
||||
|
||||
public CloudClientException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public CloudClientException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.cygnus.client.security;
|
||||
|
||||
import com.cygnus.client.CloudClientProperties;
|
||||
import com.cygnus.client.model.EncryptedLoginRequest;
|
||||
import com.cygnus.client.model.LoginPayload;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.OAEPParameterSpec;
|
||||
import javax.crypto.spec.PSource;
|
||||
|
||||
public class LoginEnvelopeEncryptor {
|
||||
|
||||
private static final OAEPParameterSpec OAEP_SHA_256 = new OAEPParameterSpec(
|
||||
"SHA-256",
|
||||
"MGF1",
|
||||
MGF1ParameterSpec.SHA256,
|
||||
PSource.PSpecified.DEFAULT);
|
||||
|
||||
private final CloudClientProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SecureRandom secureRandom;
|
||||
private volatile PublicKey publicKey;
|
||||
|
||||
public LoginEnvelopeEncryptor(
|
||||
CloudClientProperties properties, ObjectMapper objectMapper) {
|
||||
this(properties, objectMapper, new SecureRandom());
|
||||
}
|
||||
|
||||
LoginEnvelopeEncryptor(
|
||||
CloudClientProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
SecureRandom secureRandom) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.secureRandom = secureRandom;
|
||||
}
|
||||
|
||||
public EncryptedLoginRequest encrypt(LoginPayload payload) {
|
||||
try {
|
||||
KeyGenerator generator = KeyGenerator.getInstance("AES");
|
||||
generator.init(256, secureRandom);
|
||||
SecretKey aesKey = generator.generateKey();
|
||||
byte[] initializationVector = new byte[12];
|
||||
secureRandom.nextBytes(initializationVector);
|
||||
|
||||
Cipher payloadCipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
payloadCipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
aesKey,
|
||||
new GCMParameterSpec(128, initializationVector));
|
||||
payloadCipher.updateAAD(
|
||||
properties.loginKeyId().getBytes(StandardCharsets.UTF_8));
|
||||
byte[] encryptedPayload =
|
||||
payloadCipher.doFinal(objectMapper.writeValueAsBytes(payload));
|
||||
|
||||
Cipher keyCipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
|
||||
keyCipher.init(Cipher.ENCRYPT_MODE, publicKey(), OAEP_SHA_256);
|
||||
byte[] encryptedKey = keyCipher.doFinal(aesKey.getEncoded());
|
||||
|
||||
Base64.Encoder base64 = Base64.getEncoder();
|
||||
return new EncryptedLoginRequest(
|
||||
properties.loginKeyId(),
|
||||
base64.encodeToString(encryptedKey),
|
||||
base64.encodeToString(initializationVector),
|
||||
base64.encodeToString(encryptedPayload));
|
||||
} catch (Exception exception) {
|
||||
throw new CloudClientException("Unable to encrypt cloud login request", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PublicKey publicKey() throws Exception {
|
||||
PublicKey loaded = publicKey;
|
||||
if (loaded != null) {
|
||||
return loaded;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (publicKey == null) {
|
||||
String pem;
|
||||
String location = properties.loginPublicKeyLocation();
|
||||
if (location.startsWith("classpath:")) {
|
||||
String resourceName = location.substring("classpath:".length());
|
||||
try (InputStream stream = Thread.currentThread()
|
||||
.getContextClassLoader()
|
||||
.getResourceAsStream(resourceName)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Public key resource not found: " + resourceName);
|
||||
}
|
||||
pem = new String(stream.readAllBytes(), StandardCharsets.US_ASCII);
|
||||
}
|
||||
} else {
|
||||
String fileName =
|
||||
location.startsWith("file:") ? location.substring(5) : location;
|
||||
pem = Files.readString(Path.of(fileName), StandardCharsets.US_ASCII);
|
||||
}
|
||||
String encoded = pem
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
publicKey = KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
}
|
||||
return publicKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.client.security;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface MachineTokenProvider {
|
||||
Mono<String> accessToken();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.cygnus.client.security;
|
||||
|
||||
import com.cygnus.client.CloudClientProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class OAuthMachineTokenProvider implements MachineTokenProvider {
|
||||
|
||||
private static final String ASSERTION_TYPE =
|
||||
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
||||
|
||||
private final WebClient webClient;
|
||||
private final CloudClientProperties properties;
|
||||
private final Clock clock;
|
||||
private final AtomicReference<CachedToken> cachedToken = new AtomicReference<>();
|
||||
private volatile Mono<String> tokenRequest;
|
||||
|
||||
public OAuthMachineTokenProvider(
|
||||
WebClient webClient, CloudClientProperties properties, Clock clock) {
|
||||
this.webClient = webClient;
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<String> accessToken() {
|
||||
CachedToken current = cachedToken.get();
|
||||
if (current != null
|
||||
&& clock.instant().isBefore(
|
||||
current.expiresAt().minus(properties.tokenRefreshSkew()))) {
|
||||
return Mono.just(current.value());
|
||||
}
|
||||
return refreshToken();
|
||||
}
|
||||
|
||||
private synchronized Mono<String> refreshToken() {
|
||||
CachedToken current = cachedToken.get();
|
||||
if (current != null
|
||||
&& clock.instant().isBefore(
|
||||
current.expiresAt().minus(properties.tokenRefreshSkew()))) {
|
||||
return Mono.just(current.value());
|
||||
}
|
||||
if (tokenRequest == null) {
|
||||
tokenRequest = webClient.post()
|
||||
.uri(properties.tokenUri())
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(BodyInserters.fromFormData("grant_type", "client_credentials")
|
||||
.with("client_id", properties.clientId())
|
||||
.with("client_assertion_type", ASSERTION_TYPE)
|
||||
.with("client_assertion", clientAssertion())
|
||||
.with("scope", "identity.login"))
|
||||
.retrieve()
|
||||
.bodyToMono(TokenResponse.class)
|
||||
.map(response -> {
|
||||
cachedToken.set(new CachedToken(
|
||||
response.accessToken(),
|
||||
clock.instant().plusSeconds(response.expiresIn())));
|
||||
return response.accessToken();
|
||||
})
|
||||
.doFinally(signal -> clearInFlight())
|
||||
.cache();
|
||||
}
|
||||
return tokenRequest;
|
||||
}
|
||||
|
||||
private synchronized void clearInFlight() {
|
||||
tokenRequest = null;
|
||||
}
|
||||
|
||||
private String clientAssertion() {
|
||||
String configured = properties.clientAssertion();
|
||||
if (!configured.startsWith("file:")) {
|
||||
return configured;
|
||||
}
|
||||
try {
|
||||
return Files.readString(
|
||||
Path.of(configured.substring("file:".length())),
|
||||
StandardCharsets.US_ASCII)
|
||||
.trim();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Unable to read machine client assertion", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record CachedToken(String value, Instant expiresAt) {
|
||||
}
|
||||
|
||||
private record TokenResponse(
|
||||
@JsonProperty("access_token") String accessToken,
|
||||
@JsonProperty("expires_in") long expiresIn,
|
||||
@JsonProperty("token_type") String tokenType) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.cygnus.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CloudClientPropertiesTest {
|
||||
|
||||
@Test
|
||||
void configurationRequiresMachineCredentials() {
|
||||
CloudClientProperties properties = properties("", "", "");
|
||||
|
||||
assertThrows(IllegalStateException.class, properties::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void configurationAcceptsCompleteMachineIdentity() {
|
||||
CloudClientProperties properties =
|
||||
properties("customer-a", "site-01", "signed-assertion");
|
||||
|
||||
assertDoesNotThrow(properties::validate);
|
||||
}
|
||||
|
||||
private CloudClientProperties properties(
|
||||
String clientId,
|
||||
String installationId,
|
||||
String assertion) {
|
||||
return new CloudClientProperties(
|
||||
URI.create("https://cloud.example.test"),
|
||||
URI.create("https://identity.example.test/oauth2/token"),
|
||||
clientId,
|
||||
installationId,
|
||||
assertion,
|
||||
"login-key-01",
|
||||
"file:./login-public.pem",
|
||||
Duration.ofSeconds(10),
|
||||
Duration.ofSeconds(30));
|
||||
}
|
||||
}
|
||||
100
cygnus-cloud-service/README.md
Normal file
100
cygnus-cloud-service/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Cygnus cloud service
|
||||
|
||||
## Identity login API
|
||||
|
||||
`POST /api/v1/identity/login` requires a valid machine JWT with the
|
||||
`identity.login` scope. The JWT must carry `client_id` and `installation_id`;
|
||||
both must equal the values inside the encrypted payload.
|
||||
|
||||
The request uses a hybrid encrypted envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"keyId": "cygnus-login-2026-01",
|
||||
"encryptedKey": "base64 RSA-OAEP-SHA256 encrypted AES key",
|
||||
"initializationVector": "base64 12-byte AES-GCM IV",
|
||||
"encryptedPayload": "base64 AES-GCM ciphertext and tag"
|
||||
}
|
||||
```
|
||||
|
||||
The AES-GCM additional authenticated data is the UTF-8 `keyId`. The decrypted
|
||||
JSON is:
|
||||
|
||||
```json
|
||||
{
|
||||
"loginId": "user",
|
||||
"password": "password",
|
||||
"clientId": "client-id-from-jwt",
|
||||
"installationId": "installation-id-from-jwt",
|
||||
"nonce": "unique-random-value",
|
||||
"issuedAt": "2026-07-23T06:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Configure the PKCS#8 RSA private key with
|
||||
`CYGNUS_LOGIN_PRIVATE_KEY=file:/secure/path/login-private.pem`. Keep this key
|
||||
outside the source tree and container image. The corresponding public key is
|
||||
distributed to the on-prem gateway.
|
||||
|
||||
The database bootstrap is
|
||||
`src/main/resources/db/identity/001_identity_login_schema.sql`. It is
|
||||
transactional and idempotent; it copies login/menu data from `matrix.public`
|
||||
to `matrix.identity`. It is intended for initial migration and controlled
|
||||
development refreshes. Do not run it after `identity` becomes the production
|
||||
system of record because its upserts intentionally refresh rows from `public`.
|
||||
|
||||
## Machine token endpoint
|
||||
|
||||
`POST /oauth2/token` implements the client-credentials flow used by the
|
||||
on-premises gateway. The client assertion must be:
|
||||
|
||||
- an inner RS256 JWT signed with the installation private key;
|
||||
- encrypted as RSA-OAEP-256 plus AES-256-GCM using the cloud assertion key;
|
||||
- bound to the configured client ID, installation ID, and token audience;
|
||||
- unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`.
|
||||
|
||||
The endpoint returns a short-lived RS256 access token carrying the client,
|
||||
installation, tenant, license, security-version, and approved-scope claims.
|
||||
The identity endpoint requires the `identity.login` scope and verifies the
|
||||
same machine and tenant binding in the encrypted login payload.
|
||||
|
||||
Generate separate cloud key pairs:
|
||||
|
||||
```bash
|
||||
mkdir -p config/keys
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
|
||||
-out config/keys/assertion-decryption-private.pem
|
||||
openssl pkey -in config/keys/assertion-decryption-private.pem -pubout \
|
||||
-out config/keys/assertion-decryption-public.pem
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
|
||||
-out config/keys/access-token-private.pem
|
||||
openssl pkey -in config/keys/access-token-private.pem -pubout \
|
||||
-out config/keys/access-token-public.pem
|
||||
chmod 600 config/keys/*private.pem
|
||||
```
|
||||
|
||||
## Dynamic tenant, installation, and license registration
|
||||
|
||||
Machine clients are no longer configured in a runtime `clients.yml`. The
|
||||
authoritative records are:
|
||||
|
||||
- `identity.client_account`: tenant identity and status;
|
||||
- `identity.client_installation`: machine identity, assertion public key,
|
||||
allowed scopes, enabled state, and security version;
|
||||
- `identity.client_license`: subscription period, package, type, status, and
|
||||
licensed limits.
|
||||
|
||||
Token issuance resolves the installation and active license through a
|
||||
Redis cache-aside service with PostgreSQL fallback. Cache entries have a
|
||||
bounded TTL and can be invalidated after administrative changes. Therefore,
|
||||
new customers, installations, key rotations, scope changes, and license
|
||||
changes do not require restarting the cloud service.
|
||||
|
||||
The login/menu queries are tenant-scoped. Tenant-owned identity tables carry
|
||||
`tenant_id`; `identity.pages` remains the shared feature catalog while
|
||||
permissions are assigned per tenant.
|
||||
|
||||
Use `scripts/setup-local-communication.sh` to create keys, register or update
|
||||
the database records, create the initial license, and generate the on-premises
|
||||
machine assertion. Never place cloud private keys, customer assertions, or
|
||||
installation private keys in the repository or container image.
|
||||
88
cygnus-cloud-service/pom.xml
Normal file
88
cygnus-cloud-service/pom.xml
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.cygnus</groupId>
|
||||
<artifactId>cygnus-parent</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>cygnus-cloud-service</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Cygnus Cloud Service</name>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vertx</groupId>
|
||||
<artifactId>vertx-pg-client</artifactId>
|
||||
<version>${vertx.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cygnus.cloud;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class CygnusCloudServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CygnusCloudServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
9
cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/CacheProperties.java
vendored
Normal file
9
cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/CacheProperties.java
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.cygnus.cloud.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties("cygnus.cache")
|
||||
public record CacheProperties(String keyPrefix, Duration defaultTtl) {
|
||||
}
|
||||
53
cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/ReactiveCacheService.java
vendored
Normal file
53
cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/ReactiveCacheService.java
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
package com.cygnus.cloud.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class ReactiveCacheService {
|
||||
|
||||
private final ReactiveStringRedisTemplate redis;
|
||||
private final CacheProperties properties;
|
||||
|
||||
public ReactiveCacheService(ReactiveStringRedisTemplate redis, CacheProperties properties) {
|
||||
this.redis = redis;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public Mono<String> get(String namespace, String key) {
|
||||
return redis.opsForValue().get(cacheKey(namespace, key));
|
||||
}
|
||||
|
||||
public Mono<Boolean> put(String namespace, String key, String value) {
|
||||
return put(namespace, key, value, properties.defaultTtl());
|
||||
}
|
||||
|
||||
public Mono<Boolean> put(String namespace, String key, String value, Duration ttl) {
|
||||
return redis.opsForValue().set(cacheKey(namespace, key), value, ttl);
|
||||
}
|
||||
|
||||
public Mono<Boolean> putIfAbsent(String namespace, String key, String value, Duration ttl) {
|
||||
return redis.opsForValue().setIfAbsent(cacheKey(namespace, key), value, ttl);
|
||||
}
|
||||
|
||||
public Mono<Boolean> evict(String namespace, String key) {
|
||||
return redis.delete(cacheKey(namespace, key)).map(deleted -> deleted > 0);
|
||||
}
|
||||
|
||||
public Mono<Long> increment(String namespace, String key, Duration ttl) {
|
||||
String fullKey = cacheKey(namespace, key);
|
||||
return redis.opsForValue()
|
||||
.increment(fullKey)
|
||||
.flatMap(count -> count == 1
|
||||
? redis.expire(fullKey, ttl).thenReturn(count)
|
||||
: Mono.just(count));
|
||||
}
|
||||
|
||||
private String cacheKey(String namespace, String key) {
|
||||
return properties.keyPrefix() + ':' + namespace + ':' + key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.cloud.database;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties("cygnus.database")
|
||||
public record DatabaseProperties(
|
||||
String host,
|
||||
int port,
|
||||
String database,
|
||||
String username,
|
||||
String password,
|
||||
boolean ssl,
|
||||
Duration connectTimeout,
|
||||
int poolSize,
|
||||
int poolWaitQueueSize) {
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.cygnus.cloud.database;
|
||||
|
||||
import io.vertx.sqlclient.Pool;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.RowSet;
|
||||
import io.vertx.sqlclient.SqlConnection;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Reactor boundary around the Vert.x PostgreSQL pool. Feature repositories
|
||||
* should use parameterized SQL and pass values through {@link Tuple}.
|
||||
*/
|
||||
@Service
|
||||
public class ReactiveDatabaseClient {
|
||||
|
||||
private final Pool pool;
|
||||
|
||||
public ReactiveDatabaseClient(Pool pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
public Mono<RowSet<Row>> query(String sql) {
|
||||
return Mono.fromCompletionStage(() -> pool.query(sql).execute().toCompletionStage());
|
||||
}
|
||||
|
||||
public Mono<RowSet<Row>> preparedQuery(String sql, Tuple parameters) {
|
||||
return Mono.fromCompletionStage(
|
||||
() -> pool.preparedQuery(sql).execute(parameters).toCompletionStage());
|
||||
}
|
||||
|
||||
public Mono<Integer> preparedUpdate(String sql, Tuple parameters) {
|
||||
return preparedQuery(sql, parameters).map(RowSet::rowCount);
|
||||
}
|
||||
|
||||
public <T> Mono<T> inTransaction(Function<SqlConnection, Mono<T>> work) {
|
||||
return Mono.usingWhen(
|
||||
Mono.fromCompletionStage(() -> pool.getConnection().toCompletionStage()),
|
||||
connection -> Mono.fromCompletionStage(
|
||||
() -> connection.begin().toCompletionStage())
|
||||
.flatMap(transaction -> work.apply(connection)
|
||||
.flatMap(result -> Mono.fromCompletionStage(
|
||||
() -> transaction.commit().toCompletionStage())
|
||||
.thenReturn(result))
|
||||
.onErrorResume(error -> Mono.fromCompletionStage(
|
||||
() -> transaction.rollback().toCompletionStage())
|
||||
.onErrorResume(ignored -> Mono.empty())
|
||||
.then(Mono.error(error)))),
|
||||
connection -> Mono.fromCompletionStage(
|
||||
() -> connection.close().toCompletionStage()),
|
||||
(connection, error) -> Mono.fromCompletionStage(
|
||||
() -> connection.close().toCompletionStage()),
|
||||
connection -> Mono.fromCompletionStage(
|
||||
() -> connection.close().toCompletionStage()));
|
||||
}
|
||||
|
||||
public Mono<RowSet<Row>> preparedQuery(
|
||||
SqlConnection connection, String sql, Tuple parameters) {
|
||||
return Mono.fromCompletionStage(
|
||||
() -> connection.preparedQuery(sql)
|
||||
.execute(parameters)
|
||||
.toCompletionStage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.cygnus.cloud.database;
|
||||
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.pgclient.PgConnectOptions;
|
||||
import io.vertx.pgclient.PgBuilder;
|
||||
import io.vertx.sqlclient.Pool;
|
||||
import io.vertx.sqlclient.PoolOptions;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class VertxDatabaseConfiguration {
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
Vertx vertx() {
|
||||
return Vertx.vertx();
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
Pool postgresPool(Vertx vertx, DatabaseProperties properties) {
|
||||
PgConnectOptions connection = new PgConnectOptions()
|
||||
.setHost(properties.host())
|
||||
.setPort(properties.port())
|
||||
.setDatabase(properties.database())
|
||||
.setUser(properties.username())
|
||||
.setPassword(properties.password())
|
||||
.setSslMode(properties.ssl()
|
||||
? io.vertx.pgclient.SslMode.REQUIRE
|
||||
: io.vertx.pgclient.SslMode.DISABLE);
|
||||
|
||||
PoolOptions pool = new PoolOptions()
|
||||
.setMaxSize(properties.poolSize())
|
||||
.setMaxWaitQueueSize(properties.poolWaitQueueSize())
|
||||
.setConnectionTimeout(Math.toIntExact(properties.connectTimeout().toMillis()))
|
||||
.setConnectionTimeoutUnit(TimeUnit.MILLISECONDS);
|
||||
|
||||
return PgBuilder.pool()
|
||||
.using(vertx)
|
||||
.connectingTo(connection)
|
||||
.with(pool)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import com.cygnus.cloud.identity.model.AuthenticatedIdentity;
|
||||
import com.cygnus.cloud.identity.service.AuthenticationException;
|
||||
import com.cygnus.cloud.identity.service.IdentityAuthenticationService;
|
||||
import com.cygnus.cloud.identity.service.LoginRequestReplayService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/identity")
|
||||
public class CloudLoginController {
|
||||
|
||||
private final RsaLoginPayloadDecryptor decryptor;
|
||||
private final IdentityAuthenticationService authenticationService;
|
||||
private final LoginRequestReplayService replayService;
|
||||
private final LoginEncryptionProperties encryptionProperties;
|
||||
private final Clock clock;
|
||||
|
||||
public CloudLoginController(
|
||||
RsaLoginPayloadDecryptor decryptor,
|
||||
IdentityAuthenticationService authenticationService,
|
||||
LoginRequestReplayService replayService,
|
||||
LoginEncryptionProperties encryptionProperties,
|
||||
Clock clock) {
|
||||
this.decryptor = decryptor;
|
||||
this.authenticationService = authenticationService;
|
||||
this.replayService = replayService;
|
||||
this.encryptionProperties = encryptionProperties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public Mono<AuthenticatedIdentity> login(
|
||||
@AuthenticationPrincipal Jwt machineJwt,
|
||||
@Valid @RequestBody EncryptedLoginRequest request,
|
||||
ServerHttpRequest serverRequest) {
|
||||
if (machineJwt == null) {
|
||||
return Mono.error(new AuthenticationException("Machine authentication required"));
|
||||
}
|
||||
|
||||
LoginPayload payload = decryptor.decrypt(request);
|
||||
validatePayload(payload);
|
||||
validateMachineBinding(machineJwt, payload);
|
||||
validateFreshness(payload);
|
||||
|
||||
return replayService
|
||||
.claim(
|
||||
payload.installationId(),
|
||||
payload.nonce(),
|
||||
encryptionProperties.payloadTtl())
|
||||
.flatMap(claimed -> {
|
||||
if (!claimed) {
|
||||
return Mono.error(new AuthenticationException("Login request replayed"));
|
||||
}
|
||||
return authenticationService.authenticate(
|
||||
tenantId(machineJwt),
|
||||
payload.loginId(),
|
||||
payload.password(),
|
||||
remoteAddress(serverRequest));
|
||||
});
|
||||
}
|
||||
|
||||
private UUID tenantId(Jwt jwt) {
|
||||
try {
|
||||
return UUID.fromString(jwt.getClaimAsString("tenant_id"));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new AuthenticationException("Machine tenant is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePayload(LoginPayload payload) {
|
||||
if (payload == null
|
||||
|| !StringUtils.hasText(payload.loginId())
|
||||
|| !StringUtils.hasText(payload.password())
|
||||
|| !StringUtils.hasText(payload.clientId())
|
||||
|| !StringUtils.hasText(payload.installationId())
|
||||
|| !StringUtils.hasText(payload.nonce())
|
||||
|| payload.issuedAt() == null) {
|
||||
throw new AuthenticationException("Invalid login payload");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMachineBinding(Jwt jwt, LoginPayload payload) {
|
||||
String authenticatedClient = jwt.getClaimAsString("client_id");
|
||||
String authenticatedInstallation = jwt.getClaimAsString("installation_id");
|
||||
if (!payload.clientId().equals(authenticatedClient)
|
||||
|| !payload.installationId().equals(authenticatedInstallation)) {
|
||||
throw new AuthenticationException("Machine identity mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateFreshness(LoginPayload payload) {
|
||||
Duration age = Duration.between(payload.issuedAt(), clock.instant()).abs();
|
||||
if (age.compareTo(encryptionProperties.payloadTtl()) > 0) {
|
||||
throw new AuthenticationException("Login request expired");
|
||||
}
|
||||
}
|
||||
|
||||
private String remoteAddress(ServerHttpRequest request) {
|
||||
return request.getRemoteAddress() == null
|
||||
? null
|
||||
: request.getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record EncryptedLoginRequest(
|
||||
@NotBlank String keyId,
|
||||
@NotBlank String encryptedKey,
|
||||
@NotBlank String initializationVector,
|
||||
@NotBlank String encryptedPayload) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import com.cygnus.cloud.identity.service.AuthenticationException;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class IdentityErrorHandler {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(IdentityErrorHandler.class);
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
Map<String, String> authenticationFailure(AuthenticationException exception) {
|
||||
LOGGER.warn("Identity authentication rejected: {}", exception.getMessage());
|
||||
return Map.of("code", "AUTHENTICATION_FAILED", "message", "Authentication failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties("cygnus.login-encryption")
|
||||
public record LoginEncryptionProperties(
|
||||
String keyId,
|
||||
String privateKeyLocation,
|
||||
Duration payloadTtl) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginPayload(
|
||||
@NotBlank String loginId,
|
||||
@NotBlank String password,
|
||||
@NotBlank String clientId,
|
||||
@NotBlank String installationId,
|
||||
@NotBlank String nonce,
|
||||
@NotNull Instant issuedAt) {
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import com.cygnus.cloud.identity.service.AuthenticationException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.OAEPParameterSpec;
|
||||
import javax.crypto.spec.PSource;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Component
|
||||
public class RsaLoginPayloadDecryptor {
|
||||
|
||||
private static final OAEPParameterSpec OAEP_SHA_256 = new OAEPParameterSpec(
|
||||
"SHA-256",
|
||||
"MGF1",
|
||||
MGF1ParameterSpec.SHA256,
|
||||
PSource.PSpecified.DEFAULT);
|
||||
|
||||
private final LoginEncryptionProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private volatile PrivateKey privateKey;
|
||||
|
||||
public RsaLoginPayloadDecryptor(
|
||||
LoginEncryptionProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
ResourceLoader resourceLoader) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.resourceLoader = resourceLoader;
|
||||
}
|
||||
|
||||
public LoginPayload decrypt(EncryptedLoginRequest request) {
|
||||
if (!properties.keyId().equals(request.keyId())) {
|
||||
throw new AuthenticationException("Unsupported encryption key");
|
||||
}
|
||||
try {
|
||||
Cipher keyCipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
|
||||
keyCipher.init(Cipher.DECRYPT_MODE, privateKey(), OAEP_SHA_256);
|
||||
byte[] aesKey =
|
||||
keyCipher.doFinal(Base64.getDecoder().decode(request.encryptedKey()));
|
||||
|
||||
Cipher payloadCipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
payloadCipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(aesKey, "AES"),
|
||||
new GCMParameterSpec(
|
||||
128,
|
||||
Base64.getDecoder().decode(request.initializationVector())));
|
||||
payloadCipher.updateAAD(request.keyId().getBytes(StandardCharsets.UTF_8));
|
||||
byte[] plaintext = payloadCipher.doFinal(
|
||||
Base64.getDecoder().decode(request.encryptedPayload()));
|
||||
return objectMapper.readValue(plaintext, LoginPayload.class);
|
||||
} catch (AuthenticationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AuthenticationException("Invalid encrypted login request");
|
||||
}
|
||||
}
|
||||
|
||||
private PrivateKey privateKey() throws Exception {
|
||||
PrivateKey loaded = privateKey;
|
||||
if (loaded != null) {
|
||||
return loaded;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (privateKey == null) {
|
||||
Resource resource =
|
||||
resourceLoader.getResource(properties.privateKeyLocation());
|
||||
String pem = resource.getContentAsString(StandardCharsets.US_ASCII);
|
||||
String encoded = pem
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
byte[] keyBytes = Base64.getDecoder().decode(encoded);
|
||||
privateKey = KeyFactory.getInstance("RSA")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
||||
}
|
||||
return privateKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.cygnus.cloud.identity.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record AuthenticatedIdentity(
|
||||
short userId,
|
||||
String loginId,
|
||||
String displayName,
|
||||
short groupId,
|
||||
String groupName,
|
||||
short branchId,
|
||||
String branchName,
|
||||
String branchCode,
|
||||
String branchLocation,
|
||||
short companyId,
|
||||
String companyName,
|
||||
String companyCode,
|
||||
Instant loginTime,
|
||||
List<MenuItem> menu) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.cloud.identity.model;
|
||||
|
||||
public record IdentityUser(
|
||||
short userId,
|
||||
String loginId,
|
||||
String displayName,
|
||||
String legacyPassword,
|
||||
short groupId,
|
||||
String groupName,
|
||||
short branchId,
|
||||
String branchName,
|
||||
String branchCode,
|
||||
String branchLocation,
|
||||
short companyId,
|
||||
String companyName,
|
||||
String companyCode,
|
||||
boolean active) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.cloud.identity.model;
|
||||
|
||||
public record MenuItem(
|
||||
short pageId,
|
||||
String label,
|
||||
String targetUrl,
|
||||
short parentPage,
|
||||
short pageOrder,
|
||||
String permission,
|
||||
String targetWindow,
|
||||
String requestValue) {
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.cygnus.cloud.identity.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.identity.model.IdentityUser;
|
||||
import com.cygnus.cloud.identity.model.MenuItem;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class IdentityRepository {
|
||||
|
||||
private static final String FIND_USER = """
|
||||
SELECT u.user_id, u.loginid, u.displayname, u.loginpassword, u.group_id,
|
||||
g.name AS group_name, u.branch_id, b.branchname, b.branchcode, b.city,
|
||||
u.company_id, c.companyname, c.companycode, u.isactive
|
||||
FROM identity.app_user u
|
||||
JOIN identity.user_group g
|
||||
ON g.tenant_id = u.tenant_id AND g.group_id = u.group_id
|
||||
JOIN identity.company c
|
||||
ON c.tenant_id = u.tenant_id AND c.company_id = u.company_id
|
||||
JOIN identity.company_branch b
|
||||
ON b.tenant_id = u.tenant_id
|
||||
AND b.branch_id = u.branch_id
|
||||
AND b.company_id = u.company_id
|
||||
WHERE u.tenant_id = $1
|
||||
AND upper(u.loginid) = upper($2)
|
||||
""";
|
||||
|
||||
private static final String FIND_MENU = """
|
||||
SELECT p.page_id, p.menulabel, p.targeturl, p.parentpage, p.pageorder,
|
||||
permissions.permission, p.targetwindow, permissions.requestval
|
||||
FROM identity.permission permissions
|
||||
JOIN identity.pages p ON p.page_id = permissions.page_id
|
||||
WHERE permissions.tenant_id = $1
|
||||
AND permissions.group_id = $2
|
||||
AND p.isvisible = 1
|
||||
AND permissions.permission <> '000'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM identity.denied_pages denied
|
||||
WHERE denied.tenant_id = permissions.tenant_id
|
||||
AND denied.user_id = $3
|
||||
AND denied.page_id = permissions.page_id
|
||||
AND denied.isdenied = 1
|
||||
)
|
||||
ORDER BY p.parentpage, p.pageorder DESC, p.page_id
|
||||
""";
|
||||
|
||||
private static final String RECORD_LOGIN = """
|
||||
INSERT INTO identity.user_loginhistory
|
||||
(tenant_id, loginid, logintime, ipaddr, user_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING uid
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
private final IdentityRowMapper mapper;
|
||||
|
||||
public IdentityRepository(ReactiveDatabaseClient database, IdentityRowMapper mapper) {
|
||||
this.database = database;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public Flux<IdentityUser> findUsersByLoginId(UUID tenantId, String loginId) {
|
||||
return database.preparedQuery(FIND_USER, Tuple.of(tenantId, loginId))
|
||||
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user));
|
||||
}
|
||||
|
||||
public Mono<List<MenuItem>> findMenu(
|
||||
UUID tenantId, short groupId, short userId) {
|
||||
return database.preparedQuery(
|
||||
FIND_MENU, Tuple.of(tenantId, groupId, userId))
|
||||
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::menuItem))
|
||||
.collectList();
|
||||
}
|
||||
|
||||
public Mono<Long> recordLogin(
|
||||
UUID tenantId,
|
||||
String loginId,
|
||||
Instant loginTime,
|
||||
String remoteAddress,
|
||||
short userId) {
|
||||
LocalDateTime databaseTime = LocalDateTime.ofInstant(loginTime, ZoneOffset.UTC);
|
||||
return database.preparedQuery(
|
||||
RECORD_LOGIN,
|
||||
Tuple.of(
|
||||
tenantId,
|
||||
loginId,
|
||||
databaseTime,
|
||||
remoteAddress,
|
||||
userId))
|
||||
.map(rows -> rows.iterator().next().getLong("uid"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.cygnus.cloud.identity.repository;
|
||||
|
||||
import com.cygnus.cloud.identity.model.IdentityUser;
|
||||
import com.cygnus.cloud.identity.model.MenuItem;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class IdentityRowMapper {
|
||||
|
||||
IdentityUser user(Row row) {
|
||||
return new IdentityUser(
|
||||
row.getShort("user_id"),
|
||||
row.getString("loginid"),
|
||||
row.getString("displayname"),
|
||||
row.getString("loginpassword"),
|
||||
row.getShort("group_id"),
|
||||
row.getString("group_name"),
|
||||
row.getShort("branch_id"),
|
||||
row.getString("branchname"),
|
||||
row.getString("branchcode"),
|
||||
row.getString("city"),
|
||||
row.getShort("company_id"),
|
||||
row.getString("companyname"),
|
||||
row.getString("companycode"),
|
||||
row.getShort("isactive") == 1);
|
||||
}
|
||||
|
||||
MenuItem menuItem(Row row) {
|
||||
return new MenuItem(
|
||||
row.getShort("page_id"),
|
||||
row.getString("menulabel"),
|
||||
row.getString("targeturl"),
|
||||
row.getShort("parentpage"),
|
||||
row.getShort("pageorder"),
|
||||
row.getString("permission").trim(),
|
||||
row.getString("targetwindow"),
|
||||
row.getString("requestval"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.identity.service;
|
||||
|
||||
public class AuthenticationException extends RuntimeException {
|
||||
|
||||
public AuthenticationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.cygnus.cloud.identity.service;
|
||||
|
||||
import com.cygnus.cloud.identity.model.AuthenticatedIdentity;
|
||||
import com.cygnus.cloud.identity.model.IdentityUser;
|
||||
import com.cygnus.cloud.identity.repository.IdentityRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class IdentityAuthenticationService {
|
||||
|
||||
private final IdentityRepository repository;
|
||||
private final LegacyPasswordVerifier passwordVerifier;
|
||||
private final Clock clock;
|
||||
|
||||
public IdentityAuthenticationService(
|
||||
IdentityRepository repository,
|
||||
LegacyPasswordVerifier passwordVerifier,
|
||||
Clock clock) {
|
||||
this.repository = repository;
|
||||
this.passwordVerifier = passwordVerifier;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Mono<AuthenticatedIdentity> authenticate(
|
||||
UUID tenantId,
|
||||
String loginId,
|
||||
String password,
|
||||
String remoteAddress) {
|
||||
return repository.findUsersByLoginId(tenantId, loginId)
|
||||
.collectList()
|
||||
.flatMap(users -> {
|
||||
if (users.isEmpty()) {
|
||||
return Mono.error(new AuthenticationException("Invalid credentials"));
|
||||
}
|
||||
java.util.List<IdentityUser> exactMatches = users.stream()
|
||||
.filter(user -> user.loginId().equals(loginId))
|
||||
.toList();
|
||||
IdentityUser user;
|
||||
if (exactMatches.size() == 1) {
|
||||
user = exactMatches.getFirst();
|
||||
} else if (users.size() == 1) {
|
||||
user = users.getFirst();
|
||||
} else {
|
||||
return Mono.error(
|
||||
new AuthenticationException("Ambiguous login identity"));
|
||||
}
|
||||
if (!user.active()
|
||||
|| !passwordVerifier.matches(password, user.legacyPassword())) {
|
||||
return Mono.error(new AuthenticationException("Invalid credentials"));
|
||||
}
|
||||
Instant loginTime = clock.instant();
|
||||
return repository.findMenu(
|
||||
tenantId, user.groupId(), user.userId())
|
||||
.flatMap(menu -> repository.recordLogin(
|
||||
tenantId,
|
||||
user.loginId(),
|
||||
loginTime,
|
||||
remoteAddress,
|
||||
user.userId())
|
||||
.thenReturn(toAuthenticatedIdentity(user, loginTime, menu)));
|
||||
});
|
||||
}
|
||||
|
||||
private AuthenticatedIdentity toAuthenticatedIdentity(
|
||||
IdentityUser user,
|
||||
Instant loginTime,
|
||||
java.util.List<com.cygnus.cloud.identity.model.MenuItem> menu) {
|
||||
return new AuthenticatedIdentity(
|
||||
user.userId(),
|
||||
user.loginId(),
|
||||
user.displayName(),
|
||||
user.groupId(),
|
||||
user.groupName(),
|
||||
user.branchId(),
|
||||
user.branchName(),
|
||||
user.branchCode(),
|
||||
user.branchLocation(),
|
||||
user.companyId(),
|
||||
user.companyName(),
|
||||
user.companyCode(),
|
||||
loginTime,
|
||||
menu);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.cygnus.cloud.identity.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Temporary compatibility verifier for passwords created by the legacy app.
|
||||
* New passwords must not be written with this algorithm.
|
||||
*/
|
||||
@Component
|
||||
public class LegacyPasswordVerifier {
|
||||
|
||||
private static final byte[] LEGACY_KEY =
|
||||
"ThisIsASecretKey".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
public boolean matches(String rawPassword, String storedPassword) {
|
||||
if (rawPassword == null || storedPassword == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(LEGACY_KEY, "AES"));
|
||||
byte[] encrypted = cipher.doFinal(rawPassword.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] expected = Base64.getMimeDecoder().decode(storedPassword);
|
||||
return MessageDigest.isEqual(encrypted, expected);
|
||||
} catch (GeneralSecurityException | IllegalArgumentException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.cygnus.cloud.identity.service;
|
||||
|
||||
import com.cygnus.cloud.cache.ReactiveCacheService;
|
||||
import java.time.Duration;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class LoginRequestReplayService {
|
||||
|
||||
private static final String NAMESPACE = "login-nonce";
|
||||
|
||||
private final ReactiveCacheService cache;
|
||||
|
||||
public LoginRequestReplayService(ReactiveCacheService cache) {
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
public Mono<Boolean> claim(String installationId, String nonce, Duration ttl) {
|
||||
return cache.putIfAbsent(
|
||||
NAMESPACE, installationId + ':' + nonce, "used", ttl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.cygnus.cloud.platform.api;
|
||||
|
||||
import com.cygnus.cloud.platform.service.ScopedDataException;
|
||||
import com.cygnus.cloud.platform.service.ScopedDataService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/platform/data")
|
||||
public class ScopedDataController {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ScopedDataController.class);
|
||||
private final ScopedDataService service;
|
||||
|
||||
public ScopedDataController(ScopedDataService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Flux<ScopedDataItem> data(
|
||||
@AuthenticationPrincipal Jwt machineJwt,
|
||||
@Valid @RequestBody ScopedDataRequest request) {
|
||||
if (machineJwt == null) throw new ScopedDataException("Machine authentication required");
|
||||
try {
|
||||
return service.fetch(UUID.fromString(machineJwt.getClaimAsString("tenant_id")), request);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ScopedDataException("Machine tenant is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
@ExceptionHandler(ScopedDataException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
Map<String, String> invalidRequest(ScopedDataException exception) {
|
||||
LOGGER.warn("Scoped platform-data request rejected: {}", exception.getMessage());
|
||||
return Map.of("code", "PLATFORM_DATA_INVALID", "message", exception.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cygnus.cloud.platform.api;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record ScopedDataItem(
|
||||
Object value,
|
||||
String label,
|
||||
String group,
|
||||
Map<String, Object> data) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cygnus.cloud.platform.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.Map;
|
||||
|
||||
public record ScopedDataRequest(
|
||||
@NotBlank String scope,
|
||||
@NotNull Map<String, Object> data) {
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.cygnus.cloud.platform.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.platform.api.ScopedDataItem;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Repository
|
||||
public class ScopedDataRepository {
|
||||
private static final String COMPANY_OPTIONS = """
|
||||
SELECT ov.opt_value AS value,
|
||||
ov.val_description AS label,
|
||||
lower(o.description) || '.' AS group_name
|
||||
FROM platform.company_options_mapping m
|
||||
JOIN platform.options o ON o.option_id = m.option_id
|
||||
JOIN platform.options_values ov ON ov.opt_value_id = m.opt_value_id
|
||||
JOIN identity.company c ON c.company_id = m.company_id
|
||||
WHERE c.tenant_id = $1
|
||||
AND m.company_id = $2
|
||||
AND o.isactive = 1
|
||||
AND ov.isactive = 1
|
||||
AND o.description = ANY($3::text[])
|
||||
ORDER BY o.description, ov.val_description
|
||||
""";
|
||||
private static final String PORTFOLIO_OPTIONS = """
|
||||
SELECT ov.opt_value AS value,
|
||||
ov.val_description AS label,
|
||||
lower(o.description) || '.' AS group_name
|
||||
FROM platform.options_mapping m
|
||||
JOIN platform.options o ON o.option_id = m.option_id
|
||||
JOIN platform.options_values ov ON ov.opt_value_id = m.opt_value_id
|
||||
JOIN identity.company c ON c.company_id = m.company_id
|
||||
JOIN platform.company_branch_portfolios cbp
|
||||
ON cbp.company_id = m.company_id
|
||||
AND cbp.portfolio_reference = m.portfolio_reference
|
||||
WHERE c.tenant_id = $1
|
||||
AND m.company_id = $2
|
||||
AND cbp.company_branch_id = $3
|
||||
AND m.portfolio_reference = $4
|
||||
AND o.isactive = 1
|
||||
AND ov.isactive = 1
|
||||
AND o.description = ANY($5::text[])
|
||||
ORDER BY o.description, ov.val_description
|
||||
""";
|
||||
private static final String REPORT_DEFINITION = """
|
||||
SELECT r.report_key, r.report_title, r.hide_leading_zero_columns,
|
||||
c.column_key, c.column_label, c.display_order, c.alignment,
|
||||
c.column_width, c.is_visible, c.is_totalled
|
||||
FROM platform.report_definition r
|
||||
JOIN platform.report_column_definition c ON c.report_key = r.report_key
|
||||
WHERE r.report_key = $1
|
||||
AND r.enabled = true
|
||||
ORDER BY c.display_order
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public ScopedDataRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Flux<ScopedDataItem> companyOptions(
|
||||
UUID tenantId, short companyId, List<String> descriptions) {
|
||||
return rows(COMPANY_OPTIONS, Tuple.of(tenantId, companyId, descriptions.toArray(String[]::new)));
|
||||
}
|
||||
|
||||
public Flux<ScopedDataItem> portfolioOptions(
|
||||
UUID tenantId, short companyId, short branchId, UUID portfolioReference,
|
||||
List<String> descriptions) {
|
||||
return rows(PORTFOLIO_OPTIONS,
|
||||
Tuple.of(tenantId, companyId, branchId, portfolioReference,
|
||||
descriptions.toArray(String[]::new)));
|
||||
}
|
||||
|
||||
public Flux<ScopedDataItem> reportDefinition(String reportKey) {
|
||||
return database.preparedQuery(REPORT_DEFINITION, Tuple.of(reportKey))
|
||||
.flatMapMany(result -> Flux.fromIterable(result))
|
||||
.map(row -> new ScopedDataItem(null, null, null, Map.of(
|
||||
"reportKey", row.getString("report_key"),
|
||||
"reportTitle", row.getString("report_title"),
|
||||
"hideLeadingZeroColumns", row.getBoolean("hide_leading_zero_columns"),
|
||||
"columnKey", row.getString("column_key"),
|
||||
"columnLabel", row.getString("column_label"),
|
||||
"displayOrder", row.getInteger("display_order"),
|
||||
"alignment", row.getString("alignment"),
|
||||
"columnWidth", row.getInteger("column_width"),
|
||||
"visible", row.getBoolean("is_visible"),
|
||||
"totalled", row.getBoolean("is_totalled"))));
|
||||
}
|
||||
|
||||
private Flux<ScopedDataItem> rows(String sql, Tuple parameters) {
|
||||
return database.preparedQuery(sql, parameters)
|
||||
.flatMapMany(result -> Flux.fromIterable(result))
|
||||
.map(this::item);
|
||||
}
|
||||
|
||||
private ScopedDataItem item(Row row) {
|
||||
return new ScopedDataItem(
|
||||
row.getValue("value"), row.getString("label"), row.getString("group_name"), Map.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.cloud.platform.service;
|
||||
|
||||
public class ScopedDataException extends RuntimeException {
|
||||
public ScopedDataException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.cygnus.cloud.platform.service;
|
||||
|
||||
import com.cygnus.cloud.platform.api.ScopedDataItem;
|
||||
import com.cygnus.cloud.platform.api.ScopedDataRequest;
|
||||
import com.cygnus.cloud.platform.repository.ScopedDataRepository;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Service
|
||||
public class ScopedDataService {
|
||||
public static final String COMPANY_OPTIONS = "company-options";
|
||||
public static final String PORTFOLIO_OPTIONS = "portfolio-options";
|
||||
public static final String REPORT_DEFINITION = "report-definition";
|
||||
|
||||
private final ScopedDataRepository repository;
|
||||
|
||||
public ScopedDataService(ScopedDataRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public Flux<ScopedDataItem> fetch(UUID tenantId, ScopedDataRequest request) {
|
||||
Map<String, Object> data = request.data();
|
||||
String scope = request.scope().trim().toLowerCase(Locale.ROOT);
|
||||
if (REPORT_DEFINITION.equals(scope)) {
|
||||
return repository.reportDefinition(textValue(data, "reportKey"));
|
||||
}
|
||||
short companyId = shortValue(data, "companyId");
|
||||
List<String> descriptions = descriptions(data);
|
||||
return switch (scope) {
|
||||
case COMPANY_OPTIONS -> repository.companyOptions(tenantId, companyId, descriptions);
|
||||
case PORTFOLIO_OPTIONS -> repository.portfolioOptions(
|
||||
tenantId, companyId, shortValue(data, "branchId"),
|
||||
uuidValue(data, "portfolioReference"), descriptions);
|
||||
default -> Flux.error(new ScopedDataException("Unsupported data scope"));
|
||||
};
|
||||
}
|
||||
|
||||
private String textValue(Map<String, Object> data, String name) {
|
||||
String value = String.valueOf(data.getOrDefault(name, "")).trim();
|
||||
if (value.isEmpty() || value.length() > 100 || !value.matches("[a-z0-9-]+")) {
|
||||
throw new ScopedDataException(name + " is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private UUID uuidValue(Map<String, Object> data, String name) {
|
||||
try {
|
||||
return UUID.fromString(String.valueOf(data.get(name)));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new ScopedDataException(name + " must be a valid UUID");
|
||||
}
|
||||
}
|
||||
|
||||
private short shortValue(Map<String, Object> data, String name) {
|
||||
Object value = data.get(name);
|
||||
try {
|
||||
short result = value instanceof Number number
|
||||
? number.shortValue() : Short.parseShort(String.valueOf(value));
|
||||
if (result <= 0) throw new NumberFormatException();
|
||||
return result;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new ScopedDataException(name + " must be a positive number");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> descriptions(Map<String, Object> data) {
|
||||
Object value = data.get("descriptions");
|
||||
if (!(value instanceof List<?> values)) {
|
||||
throw new ScopedDataException("descriptions must be a non-empty list");
|
||||
}
|
||||
List<String> result = values.stream()
|
||||
.map(String::valueOf)
|
||||
.map(String::trim)
|
||||
.filter(text -> !text.isEmpty())
|
||||
.map(text -> text.toUpperCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
if (result.isEmpty() || result.size() > 20) {
|
||||
throw new ScopedDataException("descriptions must contain between 1 and 20 values");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
public record CloudQuery(int queryId, String query) {
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/queries")
|
||||
public class CloudQueryController {
|
||||
|
||||
private final QueryCatalogRepository repository;
|
||||
|
||||
public CloudQueryController(QueryCatalogRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@GetMapping("/{queryId}")
|
||||
public Mono<CloudQuery> query(
|
||||
@PathVariable @Min(1) int queryId) {
|
||||
return repository.findEnabled(queryId)
|
||||
.switchIfEmpty(Mono.error(new QueryNotFoundException(queryId)));
|
||||
}
|
||||
|
||||
@GetMapping("/key/{queryKey}")
|
||||
public Mono<CloudQuery> queryByKey(@PathVariable String queryKey) {
|
||||
return repository.findEnabled(queryKey)
|
||||
.switchIfEmpty(Mono.error(new QueryNotFoundException(queryKey)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class QueryCatalogRepository {
|
||||
|
||||
private static final String INITIALIZE = """
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
CREATE SEQUENCE IF NOT EXISTS platform.application_query_id_seq;
|
||||
CREATE TABLE IF NOT EXISTS platform.application_query (
|
||||
query_id integer PRIMARY KEY DEFAULT nextval('platform.application_query_id_seq'),
|
||||
query_key varchar(100),
|
||||
query_text text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
CONSTRAINT ck_platform_application_query_id
|
||||
CHECK (query_id > 0),
|
||||
CONSTRAINT ck_platform_application_query_text
|
||||
CHECK (length(btrim(query_text)) > 0)
|
||||
);
|
||||
ALTER TABLE platform.application_query
|
||||
ADD COLUMN IF NOT EXISTS query_key varchar(100);
|
||||
ALTER TABLE platform.application_query ALTER COLUMN query_id
|
||||
SET DEFAULT nextval('platform.application_query_id_seq');
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_platform_application_query_key
|
||||
ON platform.application_query (query_key) WHERE query_key IS NOT NULL;
|
||||
SELECT setval('platform.application_query_id_seq',
|
||||
greatest(coalesce((SELECT max(query_id) FROM platform.application_query), 0) + 1, 1), false)
|
||||
""";
|
||||
private static final String FIND = """
|
||||
SELECT query_id, query_text
|
||||
FROM platform.application_query
|
||||
WHERE query_id = $1
|
||||
AND enabled = true
|
||||
""";
|
||||
private static final String FIND_BY_KEY = """
|
||||
SELECT query_id, query_text
|
||||
FROM platform.application_query
|
||||
WHERE query_key = $1
|
||||
AND enabled = true
|
||||
""";
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public QueryCatalogRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Mono<Void> initialize() {
|
||||
return database.query(INITIALIZE).then();
|
||||
}
|
||||
|
||||
public Mono<CloudQuery> findEnabled(int queryId) {
|
||||
return database.preparedQuery(FIND, Tuple.of(queryId))
|
||||
.flatMapMany(rows -> reactor.core.publisher.Flux.fromIterable(rows))
|
||||
.next()
|
||||
.map(row -> new CloudQuery(
|
||||
row.getInteger("query_id"), row.getString("query_text")));
|
||||
}
|
||||
|
||||
public Mono<CloudQuery> findEnabled(String queryKey) {
|
||||
return database.preparedQuery(FIND_BY_KEY, Tuple.of(queryKey))
|
||||
.flatMapMany(rows -> reactor.core.publisher.Flux.fromIterable(rows))
|
||||
.next()
|
||||
.map(row -> new CloudQuery(row.getInteger("query_id"), row.getString("query_text")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class QueryCatalogSchemaInitializer implements ApplicationRunner {
|
||||
|
||||
private final QueryCatalogRepository repository;
|
||||
|
||||
public QueryCatalogSchemaInitializer(QueryCatalogRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments arguments) {
|
||||
repository.initialize()
|
||||
.block(Duration.ofMinutes(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public class QueryNotFoundException extends RuntimeException {
|
||||
|
||||
public QueryNotFoundException(int queryId) {
|
||||
super("Query was not found: " + queryId);
|
||||
}
|
||||
|
||||
public QueryNotFoundException(String queryKey) {
|
||||
super("Query was not found: " + queryKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
class AccessTokenIssuer {
|
||||
|
||||
private final CommunicationSecurityProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
AccessTokenIssuer(CommunicationSecurityProperties properties, Clock clock) {
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
TokenResponse issue(MachineClientPrincipal principal, Set<String> scopes) {
|
||||
try {
|
||||
Instant issuedAt = clock.instant();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||
.issuer(properties.issuerUri())
|
||||
.subject(principal.clientId())
|
||||
.audience(properties.audience())
|
||||
.issueTime(Date.from(issuedAt))
|
||||
.expirationTime(Date.from(expiresAt))
|
||||
.jwtID(UUID.randomUUID().toString())
|
||||
.claim("client_id", principal.clientId())
|
||||
.claim("installation_id", principal.installationId())
|
||||
.claim("installation_uuid",
|
||||
principal.internalInstallationId().toString())
|
||||
.claim("tenant_id", principal.tenantId().toString())
|
||||
.claim("license_id", principal.licenseId().toString())
|
||||
.claim("license_type", principal.licenseType())
|
||||
.claim("package_code", principal.packageCode())
|
||||
.claim("security_version", principal.securityVersion())
|
||||
.claim("scope", String.join(" ", scopes))
|
||||
.build();
|
||||
SignedJWT jwt = new SignedJWT(
|
||||
new JWSHeader.Builder(JWSAlgorithm.RS256)
|
||||
.keyID("cygnus-access-token")
|
||||
.build(),
|
||||
claims);
|
||||
jwt.sign(new RSASSASigner(
|
||||
PemKeyLoader.privateKey(properties.accessTokenPrivateKey())));
|
||||
return new TokenResponse(
|
||||
jwt.serialize(),
|
||||
"Bearer",
|
||||
properties.accessTokenTtl().toSeconds(),
|
||||
String.join(" ", scopes));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Unable to issue access token", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private static final OAuth2Error INVALID_AUDIENCE =
|
||||
new OAuth2Error("invalid_token", "Required token audience is missing", null);
|
||||
|
||||
private final String audience;
|
||||
|
||||
AudienceValidator(String audience) {
|
||||
this.audience = audience;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
return jwt.getAudience().contains(audience)
|
||||
? OAuth2TokenValidatorResult.success()
|
||||
: OAuth2TokenValidatorResult.failure(INVALID_AUDIENCE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import com.nimbusds.jose.EncryptionMethod;
|
||||
import com.nimbusds.jose.JWEAlgorithm;
|
||||
import com.nimbusds.jose.JWEObject;
|
||||
import com.nimbusds.jose.crypto.RSADecrypter;
|
||||
import com.nimbusds.jose.crypto.RSASSAVerifier;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import com.cygnus.cloud.tenant.model.ClientInstallation;
|
||||
import com.cygnus.cloud.tenant.model.ClientLicense;
|
||||
import com.cygnus.cloud.tenant.service.TenantRegistrationService;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
class ClientAssertionValidator {
|
||||
|
||||
private final CommunicationSecurityProperties properties;
|
||||
private final TenantRegistrationService registrations;
|
||||
private final Clock clock;
|
||||
|
||||
ClientAssertionValidator(
|
||||
CommunicationSecurityProperties properties,
|
||||
TenantRegistrationService registrations,
|
||||
Clock clock) {
|
||||
this.properties = properties;
|
||||
this.registrations = registrations;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
Mono<MachineClientPrincipal> validate(String clientId, String encryptedAssertion) {
|
||||
try {
|
||||
JWEObject jwe = JWEObject.parse(encryptedAssertion);
|
||||
if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm())
|
||||
|| !EncryptionMethod.A256GCM.equals(jwe.getHeader().getEncryptionMethod())) {
|
||||
throw invalid();
|
||||
}
|
||||
jwe.decrypt(new RSADecrypter(
|
||||
PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey())));
|
||||
|
||||
SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString());
|
||||
JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
|
||||
String installationCode = claims.getStringClaim("installation_id");
|
||||
if (installationCode == null || installationCode.isBlank()) {
|
||||
throw invalid();
|
||||
}
|
||||
return registrations.findInstallation(clientId, installationCode)
|
||||
.switchIfEmpty(Mono.error(invalid()))
|
||||
.flatMap(installation -> registrations
|
||||
.findCurrentLicense(installation.tenantId(), clock.instant())
|
||||
.filter(license -> license.isActiveAt(clock.instant()))
|
||||
.switchIfEmpty(Mono.error(new MachineAuthenticationException(
|
||||
"Client license is not active")))
|
||||
.map(license -> verify(
|
||||
clientId, signedJwt, claims, installation, license))
|
||||
.flatMap(principal -> registrations
|
||||
.touchInstallation(
|
||||
installation.installationId(), clock.instant())
|
||||
.thenReturn(principal)))
|
||||
.onErrorMap(
|
||||
exception -> !(exception instanceof MachineAuthenticationException),
|
||||
exception -> new MachineAuthenticationException(
|
||||
"Invalid client assertion", exception));
|
||||
} catch (MachineAuthenticationException exception) {
|
||||
return Mono.error(exception);
|
||||
} catch (Exception exception) {
|
||||
return Mono.error(new MachineAuthenticationException(
|
||||
"Invalid client assertion", exception));
|
||||
}
|
||||
}
|
||||
|
||||
private MachineClientPrincipal verify(
|
||||
String clientId,
|
||||
SignedJWT signedJwt,
|
||||
JWTClaimsSet claims,
|
||||
ClientInstallation installation,
|
||||
ClientLicense license) {
|
||||
try {
|
||||
if (!signedJwt.verify(new RSASSAVerifier(
|
||||
PemKeyLoader.publicKey(installation.assertionPublicKey())))) {
|
||||
throw invalid();
|
||||
}
|
||||
validateClaims(clientId, installation, claims);
|
||||
return new MachineClientPrincipal(
|
||||
clientId,
|
||||
installation.installationCode(),
|
||||
installation.tenantId(),
|
||||
installation.installationId(),
|
||||
license.licenseId(),
|
||||
license.licenseType(),
|
||||
license.packageCode(),
|
||||
installation.securityVersion(),
|
||||
installation.allowedScopes());
|
||||
} catch (MachineAuthenticationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new MachineAuthenticationException(
|
||||
"Invalid client assertion", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateClaims(
|
||||
String clientId,
|
||||
ClientInstallation installation,
|
||||
JWTClaimsSet claims) throws Exception {
|
||||
Instant now = clock.instant();
|
||||
Date issuedAt = claims.getIssueTime();
|
||||
Date expiresAt = claims.getExpirationTime();
|
||||
if (!clientId.equals(claims.getIssuer())
|
||||
|| !clientId.equals(claims.getSubject())
|
||||
|| !claims.getAudience().contains(properties.tokenAudience())
|
||||
|| !installation.installationCode().equals(
|
||||
claims.getStringClaim("installation_id"))
|
||||
|| issuedAt == null
|
||||
|| expiresAt == null
|
||||
|| now.isBefore(issuedAt.toInstant().minusSeconds(60))
|
||||
|| !now.isBefore(expiresAt.toInstant())) {
|
||||
throw invalid();
|
||||
}
|
||||
Duration lifetime = Duration.between(
|
||||
issuedAt.toInstant(), expiresAt.toInstant());
|
||||
if (lifetime.isNegative() || lifetime.compareTo(properties.assertionTtl()) > 0) {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private MachineAuthenticationException invalid() {
|
||||
return new MachineAuthenticationException("Invalid client assertion");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import static org.springframework.security.config.Customizer.withDefaults;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
|
||||
@Configuration
|
||||
public class CloudSecurityConfiguration {
|
||||
|
||||
@Bean
|
||||
SecurityWebFilterChain cloudSecurityFilterChain(
|
||||
ServerHttpSecurity http,
|
||||
CommunicationSecurityProperties properties) {
|
||||
http.csrf(ServerHttpSecurity.CsrfSpec::disable);
|
||||
|
||||
if (!properties.enabled()) {
|
||||
return http.authorizeExchange(exchange -> exchange.anyExchange().permitAll()).build();
|
||||
}
|
||||
|
||||
return http
|
||||
.authorizeExchange(exchange -> exchange
|
||||
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||
.pathMatchers("/oauth2/token").permitAll()
|
||||
.pathMatchers(
|
||||
"/api/v1/installations/activation/validate",
|
||||
"/api/v1/installations/register")
|
||||
.permitAll()
|
||||
.pathMatchers("/api/v1/identity/login")
|
||||
.hasAuthority("SCOPE_identity.login")
|
||||
.pathMatchers("/api/v1/queries/**")
|
||||
.hasAuthority("SCOPE_identity.login")
|
||||
.pathMatchers("/api/v1/platform/data")
|
||||
.hasAuthority("SCOPE_identity.login")
|
||||
.pathMatchers("/api/v1/admin/**")
|
||||
.hasAuthority("SCOPE_cygnus.admin")
|
||||
.anyExchange().authenticated())
|
||||
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "cygnus.security.enabled", havingValue = "true")
|
||||
ReactiveJwtDecoder reactiveJwtDecoder(CommunicationSecurityProperties properties) {
|
||||
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
|
||||
.withPublicKey(PemKeyLoader.publicKey(properties.accessTokenPublicKey()))
|
||||
.build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<Jwt>(
|
||||
JwtValidators.createDefaultWithIssuer(properties.issuerUri()),
|
||||
new AudienceValidator(properties.audience())));
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties("cygnus.security")
|
||||
public record CommunicationSecurityProperties(
|
||||
boolean enabled,
|
||||
String issuerUri,
|
||||
String audience,
|
||||
String tokenAudience,
|
||||
Duration assertionTtl,
|
||||
Duration accessTokenTtl,
|
||||
String assertionDecryptionPrivateKey,
|
||||
String accessTokenPrivateKey,
|
||||
String accessTokenPublicKey) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import com.cygnus.cloud.cache.ReactiveCacheService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class JwtReplayProtectionService {
|
||||
|
||||
private static final String NAMESPACE = "jwt-jti";
|
||||
|
||||
private final ReactiveCacheService cache;
|
||||
|
||||
public JwtReplayProtectionService(ReactiveCacheService cache) {
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
public Mono<Boolean> claim(String jwtId, Duration remainingLifetime) {
|
||||
return cache.putIfAbsent(NAMESPACE, jwtId, "used", remainingLifetime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
final class MachineAuthenticationException extends RuntimeException {
|
||||
|
||||
MachineAuthenticationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
MachineAuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
record MachineClientPrincipal(
|
||||
String clientId,
|
||||
String installationId,
|
||||
UUID tenantId,
|
||||
UUID internalInstallationId,
|
||||
UUID licenseId,
|
||||
String licenseType,
|
||||
String packageCode,
|
||||
int securityVersion,
|
||||
Set<String> allowedScopes) {
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(
|
||||
prefix = "cygnus.security",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
class MachineSecurityConfigurationValidator implements InitializingBean {
|
||||
|
||||
private final CommunicationSecurityProperties properties;
|
||||
|
||||
MachineSecurityConfigurationValidator(
|
||||
CommunicationSecurityProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
requireUri("issuer-uri", properties.issuerUri());
|
||||
requireText("audience", properties.audience());
|
||||
requireUri("token-audience", properties.tokenAudience());
|
||||
requirePositive("assertion-ttl", properties.assertionTtl());
|
||||
requirePositive("access-token-ttl", properties.accessTokenTtl());
|
||||
requireText(
|
||||
"assertion-decryption-private-key",
|
||||
properties.assertionDecryptionPrivateKey());
|
||||
requireText("access-token-private-key", properties.accessTokenPrivateKey());
|
||||
requireText("access-token-public-key", properties.accessTokenPublicKey());
|
||||
|
||||
}
|
||||
|
||||
private void requireUri(String name, String value) {
|
||||
requireText(name, value);
|
||||
try {
|
||||
URI uri = URI.create(value);
|
||||
if (!uri.isAbsolute()) {
|
||||
throw invalid(name + " must be an absolute URI");
|
||||
}
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(name + " must be a valid absolute URI");
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePositive(String name, Duration value) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw invalid(name + " must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireText(String name, String value) {
|
||||
if (isBlank(value)) {
|
||||
throw invalid(name + " is required");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private IllegalStateException invalid(String detail) {
|
||||
return new IllegalStateException(
|
||||
"Invalid cygnus.security configuration: " + detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
class MachineTokenController {
|
||||
|
||||
private static final String CLIENT_CREDENTIALS = "client_credentials";
|
||||
private static final String ASSERTION_TYPE =
|
||||
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
||||
|
||||
private final ClientAssertionValidator assertionValidator;
|
||||
private final AccessTokenIssuer tokenIssuer;
|
||||
|
||||
MachineTokenController(
|
||||
ClientAssertionValidator assertionValidator,
|
||||
AccessTokenIssuer tokenIssuer) {
|
||||
this.assertionValidator = assertionValidator;
|
||||
this.tokenIssuer = tokenIssuer;
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
value = "/oauth2/token",
|
||||
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
Mono<Map<String, Object>> token(ServerWebExchange exchange) {
|
||||
return exchange.getFormData().flatMap(this::issueToken);
|
||||
}
|
||||
|
||||
Mono<Map<String, Object>> issueToken(MultiValueMap<String, String> form) {
|
||||
if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type"))
|
||||
|| !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) {
|
||||
throw new MachineAuthenticationException("Unsupported token request");
|
||||
}
|
||||
String clientId = required(form, "client_id");
|
||||
Set<String> requestedScopes = scopes(form.getFirst("scope"));
|
||||
return assertionValidator.validate(
|
||||
clientId, required(form, "client_assertion"))
|
||||
.map(principal -> {
|
||||
if (requestedScopes.isEmpty()
|
||||
|| !principal.allowedScopes().containsAll(requestedScopes)) {
|
||||
throw new MachineAuthenticationException(
|
||||
"Invalid requested scope");
|
||||
}
|
||||
return tokenIssuer.issue(principal, requestedScopes)
|
||||
.asOAuthResponse();
|
||||
});
|
||||
}
|
||||
|
||||
private String required(MultiValueMap<String, String> form, String name) {
|
||||
String value = form.getFirst(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new MachineAuthenticationException("Invalid token request");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private Set<String> scopes(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
return new LinkedHashSet<>(Arrays.asList(value.trim().split("\\s+")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice(assignableTypes = MachineTokenController.class)
|
||||
class MachineTokenErrorHandler {
|
||||
|
||||
@ExceptionHandler(MachineAuthenticationException.class)
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
Map<String, String> invalidClient() {
|
||||
return Map.of("error", "invalid_client");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
final class PemKeyLoader {
|
||||
|
||||
private static final ConcurrentHashMap<String, RSAPrivateKey> PRIVATE_KEYS =
|
||||
new ConcurrentHashMap<>();
|
||||
private static final ConcurrentHashMap<String, RSAPublicKey> PUBLIC_KEYS =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private PemKeyLoader() {
|
||||
}
|
||||
|
||||
static RSAPrivateKey privateKey(String location) {
|
||||
return PRIVATE_KEYS.computeIfAbsent(location, PemKeyLoader::loadPrivateKey);
|
||||
}
|
||||
|
||||
private static RSAPrivateKey loadPrivateKey(String location) {
|
||||
try {
|
||||
String encoded = read(location)
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
PrivateKey key = KeyFactory.getInstance("RSA")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
return (RSAPrivateKey) key;
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Unable to load RSA private key", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static RSAPublicKey publicKey(String location) {
|
||||
return PUBLIC_KEYS.computeIfAbsent(location, PemKeyLoader::loadPublicKey);
|
||||
}
|
||||
|
||||
private static RSAPublicKey loadPublicKey(String location) {
|
||||
try {
|
||||
String encoded = read(location)
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
PublicKey key = KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
return (RSAPublicKey) key;
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Unable to load RSA public key", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String read(String location) throws Exception {
|
||||
if (location == null || location.isBlank()) {
|
||||
throw new IllegalArgumentException("RSA key location is not configured");
|
||||
}
|
||||
if (location.contains("-----BEGIN ")) {
|
||||
return location;
|
||||
}
|
||||
if (location.startsWith("classpath:")) {
|
||||
String resource = location.substring("classpath:".length());
|
||||
try (InputStream stream = Thread.currentThread()
|
||||
.getContextClassLoader()
|
||||
.getResourceAsStream(resource)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalArgumentException("Key resource not found");
|
||||
}
|
||||
return new String(stream.readAllBytes(), StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
String file = location.startsWith("file:") ? location.substring(5) : location;
|
||||
return Files.readString(Path.of(file), StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
record TokenResponse(
|
||||
String accessToken,
|
||||
String tokenType,
|
||||
long expiresIn,
|
||||
String scope) {
|
||||
|
||||
Map<String, Object> asOAuthResponse() {
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("access_token", accessToken);
|
||||
response.put("token_type", tokenType);
|
||||
response.put("expires_in", expiresIn);
|
||||
response.put("scope", scope);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cygnus.cloud.system;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class SystemConfiguration {
|
||||
|
||||
@Bean
|
||||
Clock systemClock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cygnus.cloud.system;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/system")
|
||||
public class SystemInfoController {
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
public SystemInfoController(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@GetMapping("/info")
|
||||
public Mono<SystemInfoResponse> info() {
|
||||
return Mono.just(new SystemInfoResponse(
|
||||
"cygnus-cloud-service",
|
||||
"1.0.0-SNAPSHOT",
|
||||
Instant.now(clock)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.cygnus.cloud.system;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record SystemInfoResponse(String service, String version, Instant timestamp) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivationValidationRequest(
|
||||
@NotBlank @Size(max = 40) String clientCode,
|
||||
@NotBlank @Size(max = 80) String licenseKey,
|
||||
@NotNull UUID installationUuid,
|
||||
@NotBlank @Size(max = 40)
|
||||
@Pattern(regexp = "^[A-Za-z0-9._-]+$")
|
||||
String installerVersion) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivationValidationResponse(
|
||||
String activationToken,
|
||||
OffsetDateTime expiresAt,
|
||||
UUID tenantId,
|
||||
String tenantSlug,
|
||||
String packageCode,
|
||||
int maximumInstallations) {
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import com.cygnus.cloud.tenant.repository.ClientAdministrationRepository;
|
||||
import com.cygnus.cloud.tenant.service.LicenseKeyService;
|
||||
import com.cygnus.cloud.tenant.service.InstallationLifecycleService;
|
||||
import com.cygnus.cloud.tenant.service.RegistrationEmailService;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin")
|
||||
public class ClientAdministrationController {
|
||||
|
||||
private final ClientAdministrationRepository repository;
|
||||
private final LicenseKeyService licenseKeyService;
|
||||
private final RegistrationEmailService emailService;
|
||||
private final InstallationLifecycleService installationLifecycleService;
|
||||
|
||||
public ClientAdministrationController(
|
||||
ClientAdministrationRepository repository,
|
||||
LicenseKeyService licenseKeyService,
|
||||
RegistrationEmailService emailService,
|
||||
InstallationLifecycleService installationLifecycleService) {
|
||||
this.repository = repository;
|
||||
this.licenseKeyService = licenseKeyService;
|
||||
this.emailService = emailService;
|
||||
this.installationLifecycleService = installationLifecycleService;
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/installations/{installationId}/decommission")
|
||||
public Mono<?> decommission(
|
||||
@PathVariable("tenantId") UUID tenantId,
|
||||
@PathVariable("installationId") UUID installationId,
|
||||
@Valid @RequestBody RetireInstallationRequest request,
|
||||
Authentication authentication) {
|
||||
return installationLifecycleService.retire(
|
||||
tenantId,
|
||||
installationId,
|
||||
"DECOMMISSIONED",
|
||||
actor(authentication),
|
||||
request.reason());
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/installations/{installationId}/revoke")
|
||||
public Mono<?> revoke(
|
||||
@PathVariable("tenantId") UUID tenantId,
|
||||
@PathVariable("installationId") UUID installationId,
|
||||
@Valid @RequestBody RetireInstallationRequest request,
|
||||
Authentication authentication) {
|
||||
return installationLifecycleService.retire(
|
||||
tenantId,
|
||||
installationId,
|
||||
"REVOKED",
|
||||
actor(authentication),
|
||||
request.reason());
|
||||
}
|
||||
|
||||
@PostMapping("/client-registrations")
|
||||
public Mono<Map<String, UUID>> registration(
|
||||
@Valid @RequestBody CreateClientRegistrationRequest request,
|
||||
Authentication authentication) {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tuple values = Tuple.tuple()
|
||||
.addUUID(id)
|
||||
.addString(request.clientCode())
|
||||
.addString(request.legalCompanyName())
|
||||
.addString(request.tradeName())
|
||||
.addString(request.pan())
|
||||
.addString(request.cin())
|
||||
.addString(request.gstNumber())
|
||||
.addString(request.billingAddressLine1())
|
||||
.addString(request.billingAddressLine2())
|
||||
.addString(request.billingCity())
|
||||
.addString(request.billingState())
|
||||
.addString(request.billingPostalCode())
|
||||
.addString(request.billingCountry())
|
||||
.addString(request.billingEmail())
|
||||
.addString(request.primaryContactName())
|
||||
.addString(request.primaryContactEmail())
|
||||
.addString(request.primaryContactNumber())
|
||||
.addString(request.alternateContactName())
|
||||
.addString(request.alternateContactEmail())
|
||||
.addString(request.alternateContactNumber())
|
||||
.addLocalDate(request.contractStartDate())
|
||||
.addLocalDate(request.contractEndDate())
|
||||
.addString("ACTIVE")
|
||||
.addString(actor(authentication));
|
||||
return repository.createRegistration(values)
|
||||
.flatMap(created -> created
|
||||
? Mono.just(Map.of("registrationId", id))
|
||||
: Mono.error(new IllegalStateException(
|
||||
"Registration was not created")));
|
||||
}
|
||||
|
||||
@PostMapping("/client-registrations/{registrationId}/tenants")
|
||||
public Mono<Map<String, UUID>> tenant(
|
||||
@PathVariable("registrationId") UUID registrationId,
|
||||
@Valid @RequestBody CreateTenantRequest request) {
|
||||
UUID id = UUID.randomUUID();
|
||||
return repository.createTenant(
|
||||
id, registrationId, request.clientSlug(), request.clientName())
|
||||
.flatMap(created -> created
|
||||
? Mono.just(Map.of("tenantId", id))
|
||||
: Mono.error(new IllegalStateException("Tenant was not created")));
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/licenses")
|
||||
public Mono<Map<String, UUID>> license(
|
||||
@PathVariable("tenantId") UUID tenantId,
|
||||
@Valid @RequestBody CreateLicenseRequest request) {
|
||||
if (!request.validUntil().isAfter(request.validFrom())) {
|
||||
return Mono.error(new IllegalArgumentException(
|
||||
"License end must be after start"));
|
||||
}
|
||||
UUID id = UUID.randomUUID();
|
||||
return repository.createLicense(
|
||||
id,
|
||||
tenantId,
|
||||
request.licenseType(),
|
||||
request.packageCode(),
|
||||
request.validFrom(),
|
||||
request.validUntil(),
|
||||
request.maximumUsers(),
|
||||
request.maximumInstallations())
|
||||
.flatMap(created -> created
|
||||
? Mono.just(Map.of("licenseId", id))
|
||||
: Mono.error(new IllegalStateException("License was not created")));
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/licenses/{licenseId}/activation-key")
|
||||
public Mono<Map<String, String>> activationKey(
|
||||
@PathVariable("tenantId") UUID tenantId,
|
||||
@PathVariable("licenseId") UUID licenseId,
|
||||
@Valid @RequestBody IssueLicenseKeyRequest request,
|
||||
Authentication authentication) {
|
||||
return licenseKeyService.issue(
|
||||
tenantId, licenseId, request.expiresAt(), actor(authentication))
|
||||
.flatMap(issued -> repository
|
||||
.findLicenseDeliveryDetails(tenantId, licenseId)
|
||||
.switchIfEmpty(Mono.error(
|
||||
new IllegalArgumentException("Tenant or license not found")))
|
||||
.flatMap(details -> emailService.sendLicense(
|
||||
details.primaryContactEmail(),
|
||||
details.clientCode(),
|
||||
details.tenantSlug(),
|
||||
details.packageCode(),
|
||||
details.maximumInstallations(),
|
||||
request.expiresAt(),
|
||||
issued)
|
||||
.onErrorResume(error -> licenseKeyService
|
||||
.revoke(issued.activationKeyId())
|
||||
.then(Mono.error(error)))
|
||||
.thenReturn(Map.of(
|
||||
"activationKeyId",
|
||||
issued.activationKeyId().toString(),
|
||||
"keyHint",
|
||||
issued.keyHint(),
|
||||
"delivery",
|
||||
"EMAIL_SENT"))));
|
||||
}
|
||||
|
||||
private String actor(Authentication authentication) {
|
||||
return authentication == null ? "system" : authentication.getName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateClientRegistrationRequest(
|
||||
@NotBlank @Pattern(regexp = "^[A-Z0-9]+(?:-[A-Z0-9]+)*$")
|
||||
@Size(max = 40) String clientCode,
|
||||
@NotBlank @Size(max = 240) String legalCompanyName,
|
||||
@Size(max = 240) String tradeName,
|
||||
@Size(max = 20) String pan,
|
||||
@Size(max = 30) String cin,
|
||||
@Size(max = 30) String gstNumber,
|
||||
@Size(max = 300) String billingAddressLine1,
|
||||
@Size(max = 300) String billingAddressLine2,
|
||||
@Size(max = 120) String billingCity,
|
||||
@Size(max = 120) String billingState,
|
||||
@Size(max = 20) String billingPostalCode,
|
||||
@NotBlank @Pattern(regexp = "^[A-Z]{2}$") String billingCountry,
|
||||
@Email @Size(max = 254) String billingEmail,
|
||||
@Size(max = 160) String primaryContactName,
|
||||
@Email @Size(max = 254) String primaryContactEmail,
|
||||
@Size(max = 30) String primaryContactNumber,
|
||||
@Size(max = 160) String alternateContactName,
|
||||
@Email @Size(max = 254) String alternateContactEmail,
|
||||
@Size(max = 30) String alternateContactNumber,
|
||||
@NotNull LocalDate contractStartDate,
|
||||
LocalDate contractEndDate) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record CreateLicenseRequest(
|
||||
@NotBlank @Size(max = 30) String licenseType,
|
||||
@NotBlank @Size(max = 50) String packageCode,
|
||||
@NotNull OffsetDateTime validFrom,
|
||||
@NotNull OffsetDateTime validUntil,
|
||||
@Min(1) Integer maximumUsers,
|
||||
@Min(1) @Max(100) int maximumInstallations) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record CreateTenantRequest(
|
||||
@NotBlank @Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
@Size(max = 80) String clientSlug,
|
||||
@NotBlank @Size(max = 200) String clientName) {
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
|
||||
import com.cygnus.cloud.tenant.service.InstallationActivationService;
|
||||
import com.cygnus.cloud.tenant.service.ActivationRateLimiter;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/installations")
|
||||
public class InstallationActivationController {
|
||||
|
||||
private final InstallationActivationService activationService;
|
||||
private final ActivationRateLimiter rateLimiter;
|
||||
|
||||
public InstallationActivationController(
|
||||
InstallationActivationService activationService,
|
||||
ActivationRateLimiter rateLimiter) {
|
||||
this.activationService = activationService;
|
||||
this.rateLimiter = rateLimiter;
|
||||
}
|
||||
|
||||
@PostMapping("/activation/validate")
|
||||
public Mono<ActivationValidationResponse> validate(
|
||||
@Valid @RequestBody ActivationValidationRequest request,
|
||||
ServerHttpRequest serverRequest) {
|
||||
String sourceIp = remoteAddress(serverRequest);
|
||||
return rateLimiter.check(sourceIp, request.clientCode())
|
||||
.then(activationService.validateAndCreateSession(
|
||||
request.clientCode(),
|
||||
request.licenseKey(),
|
||||
request.installationUuid(),
|
||||
sourceIp,
|
||||
request.installerVersion()))
|
||||
.map(session -> new ActivationValidationResponse(
|
||||
session.token(),
|
||||
session.expiresAt(),
|
||||
session.tenantId(),
|
||||
session.clientSlug(),
|
||||
session.packageCode(),
|
||||
session.maxInstallations()));
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public Mono<RegisteredInstallation> register(
|
||||
@Valid @RequestBody InstallationRegistrationRequest request) {
|
||||
return activationService.register(
|
||||
request.activationToken(),
|
||||
request.installationCode(),
|
||||
request.installationName(),
|
||||
request.assertionPublicKey(),
|
||||
request.softwareVersion(),
|
||||
request.environment());
|
||||
}
|
||||
|
||||
private String remoteAddress(ServerHttpRequest request) {
|
||||
return request.getRemoteAddress() == null
|
||||
? null
|
||||
: request.getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import com.cygnus.cloud.tenant.service.InstallationActivationException;
|
||||
import com.cygnus.cloud.tenant.service.InstallationCodeConflictException;
|
||||
import com.cygnus.cloud.tenant.service.LicenseKeyException;
|
||||
import com.cygnus.cloud.tenant.service.ActivationRateLimitException;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class InstallationActivationErrorHandler {
|
||||
|
||||
@ExceptionHandler(InstallationCodeConflictException.class)
|
||||
@ResponseStatus(HttpStatus.CONFLICT)
|
||||
Map<String, String> installationCodeConflict(
|
||||
InstallationCodeConflictException exception) {
|
||||
return Map.of(
|
||||
"code", "INSTALLATION_CODE_ALREADY_EXISTS",
|
||||
"message", exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({LicenseKeyException.class, InstallationActivationException.class})
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
Map<String, String> activationFailure() {
|
||||
return Map.of(
|
||||
"code", "INSTALLATION_ACTIVATION_FAILED",
|
||||
"message", "Installation activation could not be completed");
|
||||
}
|
||||
|
||||
@ExceptionHandler(ActivationRateLimitException.class)
|
||||
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
|
||||
Map<String, String> rateLimited() {
|
||||
return Map.of(
|
||||
"code", "INSTALLATION_ACTIVATION_RATE_LIMITED",
|
||||
"message", "Too many activation attempts; retry later");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record InstallationRegistrationRequest(
|
||||
@NotBlank @Size(max = 100) String activationToken,
|
||||
@NotBlank @Size(max = 100)
|
||||
@Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
||||
String installationCode,
|
||||
@NotBlank @Size(max = 160) String installationName,
|
||||
@NotBlank @Size(max = 8192) String assertionPublicKey,
|
||||
@NotBlank @Size(max = 40)
|
||||
@Pattern(regexp = "^[A-Za-z0-9._-]+$")
|
||||
String softwareVersion,
|
||||
@NotBlank @Size(max = 30)
|
||||
@Pattern(regexp = "^[A-Za-z0-9_-]+$")
|
||||
String environment) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record IssueLicenseKeyRequest(
|
||||
@NotNull OffsetDateTime expiresAt) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record RetireInstallationRequest(
|
||||
@NotBlank @Size(max = 500) String reason) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public enum ActivationKeyStatus {
|
||||
ACTIVE,
|
||||
LOCKED,
|
||||
REVOKED,
|
||||
EXPIRED
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivationSession(
|
||||
UUID activationSessionId,
|
||||
UUID registrationId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
UUID installationUuid,
|
||||
String token,
|
||||
OffsetDateTime expiresAt,
|
||||
String clientCode,
|
||||
String clientSlug,
|
||||
String packageCode,
|
||||
int maxInstallations) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ClientAccount(
|
||||
UUID tenantId,
|
||||
String clientSlug,
|
||||
String clientName,
|
||||
ClientStatus status,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt) {
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user