Compare commits
38 Commits
main
...
edp-punchi
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
/target/
|
/target/
|
||||||
|
/config/
|
||||||
!.mvn/wrapper/maven-wrapper.jar
|
!.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|
||||||
### STS ###
|
### STS ###
|
||||||
@@ -18,8 +19,19 @@
|
|||||||
|
|
||||||
### NetBeans ###
|
### NetBeans ###
|
||||||
/nbproject/private/
|
/nbproject/private/
|
||||||
/build/
|
/build/classes/*
|
||||||
|
!/build/WebContent/
|
||||||
|
!/build/WebContent/**
|
||||||
|
/build/WebContent/ReportsBackup/
|
||||||
|
/build/WebContent/WEB-INF/lib/
|
||||||
/nbbuild/
|
/nbbuild/
|
||||||
/dist/
|
/dist/
|
||||||
/nbdist/
|
/nbdist/
|
||||||
/.nb-gradle/
|
/.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": "PT10S",
|
||||||
|
"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.
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,76 @@
|
|||||||
|
package com.cygnus.client;
|
||||||
|
|
||||||
|
import com.cygnus.client.model.CloudIdentitySession;
|
||||||
|
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.UUID;
|
||||||
|
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 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,59 @@
|
|||||||
|
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/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) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record ClientInstallation(
|
||||||
|
UUID installationId,
|
||||||
|
UUID tenantId,
|
||||||
|
String clientId,
|
||||||
|
String installationCode,
|
||||||
|
String assertionPublicKey,
|
||||||
|
Set<String> allowedScopes,
|
||||||
|
boolean enabled,
|
||||||
|
int securityVersion,
|
||||||
|
OffsetDateTime lastAuthenticatedAt) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record ClientLicense(
|
||||||
|
UUID licenseId,
|
||||||
|
UUID tenantId,
|
||||||
|
String licenseType,
|
||||||
|
String packageCode,
|
||||||
|
Instant validFrom,
|
||||||
|
Instant validUntil,
|
||||||
|
LicenseStatus status,
|
||||||
|
Integer maxUsers,
|
||||||
|
Integer maxInstallations,
|
||||||
|
OffsetDateTime updatedAt) {
|
||||||
|
|
||||||
|
public boolean isActiveAt(Instant instant) {
|
||||||
|
return status == LicenseStatus.ACTIVE
|
||||||
|
&& !instant.isBefore(validFrom)
|
||||||
|
&& instant.isBefore(validUntil);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record ClientRegistration(
|
||||||
|
UUID registrationId,
|
||||||
|
String clientCode,
|
||||||
|
String legalCompanyName,
|
||||||
|
String tradeName,
|
||||||
|
String billingEmail,
|
||||||
|
LocalDate contractStartDate,
|
||||||
|
LocalDate contractEndDate,
|
||||||
|
RegistrationStatus status,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
OffsetDateTime updatedAt,
|
||||||
|
int version) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
public enum ClientStatus {
|
||||||
|
ACTIVE,
|
||||||
|
SUSPENDED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record InstallationLifecycleResult(
|
||||||
|
UUID installationId,
|
||||||
|
UUID tenantId,
|
||||||
|
String clientId,
|
||||||
|
String installationCode,
|
||||||
|
String status) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record IssuedLicenseKey(
|
||||||
|
UUID activationKeyId,
|
||||||
|
String licenseKey,
|
||||||
|
String keyHint) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record LicenseActivationContext(
|
||||||
|
LicenseActivationKey activationKey,
|
||||||
|
String clientCode,
|
||||||
|
RegistrationStatus registrationStatus,
|
||||||
|
ClientStatus tenantStatus,
|
||||||
|
String clientSlug,
|
||||||
|
String clientName,
|
||||||
|
String packageCode,
|
||||||
|
String licenseType,
|
||||||
|
LicenseStatus licenseStatus,
|
||||||
|
Instant validFrom,
|
||||||
|
Instant validUntil,
|
||||||
|
int maxInstallations,
|
||||||
|
int consumingInstallations) {
|
||||||
|
|
||||||
|
public boolean hasCapacity() {
|
||||||
|
return consumingInstallations < maxInstallations;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record LicenseActivationKey(
|
||||||
|
UUID activationKeyId,
|
||||||
|
UUID registrationId,
|
||||||
|
UUID tenantId,
|
||||||
|
UUID licenseId,
|
||||||
|
String keyHash,
|
||||||
|
String keyHint,
|
||||||
|
ActivationKeyStatus status,
|
||||||
|
OffsetDateTime expiresAt,
|
||||||
|
int failedAttempts,
|
||||||
|
int maximumAttempts,
|
||||||
|
OffsetDateTime lockedUntil,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
OffsetDateTime lastUsedAt) {
|
||||||
|
|
||||||
|
public boolean canAttemptAt(OffsetDateTime now) {
|
||||||
|
if (expiresAt != null && !expiresAt.isAfter(now)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status == ActivationKeyStatus.ACTIVE) {
|
||||||
|
return failedAttempts < maximumAttempts;
|
||||||
|
}
|
||||||
|
return status == ActivationKeyStatus.LOCKED
|
||||||
|
&& lockedUntil != null
|
||||||
|
&& !lockedUntil.isAfter(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
public record LicenseDeliveryDetails(
|
||||||
|
String clientCode,
|
||||||
|
String tenantSlug,
|
||||||
|
String packageCode,
|
||||||
|
int maximumInstallations,
|
||||||
|
String primaryContactEmail) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
public enum LicenseStatus {
|
||||||
|
ACTIVE,
|
||||||
|
SUSPENDED,
|
||||||
|
EXPIRED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record RegisteredInstallation(
|
||||||
|
UUID installationId,
|
||||||
|
UUID installationUuid,
|
||||||
|
String clientId,
|
||||||
|
String installationCode,
|
||||||
|
int securityVersion,
|
||||||
|
String status) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cygnus.cloud.tenant.model;
|
||||||
|
|
||||||
|
public enum RegistrationStatus {
|
||||||
|
DRAFT,
|
||||||
|
ACTIVE,
|
||||||
|
SUSPENDED,
|
||||||
|
TERMINATED
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.cygnus.cloud.tenant.repository;
|
||||||
|
|
||||||
|
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||||
|
import com.cygnus.cloud.tenant.model.LicenseDeliveryDetails;
|
||||||
|
import io.vertx.sqlclient.Tuple;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class ClientAdministrationRepository {
|
||||||
|
|
||||||
|
private static final String INSERT_REGISTRATION = """
|
||||||
|
INSERT INTO identity.client_registration_details (
|
||||||
|
registration_id, client_code, legal_company_name, trade_name,
|
||||||
|
pan, cin, gst_number, billing_address_line1,
|
||||||
|
billing_address_line2, billing_city, billing_state,
|
||||||
|
billing_postal_code, billing_country, billing_email,
|
||||||
|
primary_contact_name, primary_contact_email,
|
||||||
|
primary_contact_number, alternate_contact_name,
|
||||||
|
alternate_contact_email, alternate_contact_number,
|
||||||
|
contract_start_date, contract_end_date, status, created_by)
|
||||||
|
VALUES ($1, upper($2), $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||||
|
$12, upper($13), $14, $15, $16, $17, $18, $19, $20,
|
||||||
|
$21, $22, $23, $24)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String INSERT_TENANT = """
|
||||||
|
INSERT INTO identity.client_account (
|
||||||
|
tenant_id, registration_id, client_slug, client_name,
|
||||||
|
status, security_version)
|
||||||
|
VALUES ($1, $2, $3, $4, 'ACTIVE', 1)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String INSERT_LICENSE = """
|
||||||
|
INSERT INTO identity.client_license (
|
||||||
|
license_id, tenant_id, license_type, package_code,
|
||||||
|
valid_from, valid_until, status, max_users,
|
||||||
|
max_installations)
|
||||||
|
SELECT $1, account.tenant_id, $3, $4, $5, $6, 'ACTIVE', $7, $8
|
||||||
|
FROM identity.client_account account
|
||||||
|
WHERE account.tenant_id = $2
|
||||||
|
AND account.status = 'ACTIVE'
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String FIND_LICENSE_DELIVERY_DETAILS = """
|
||||||
|
SELECT registration.client_code,
|
||||||
|
registration.primary_contact_email,
|
||||||
|
account.client_slug,
|
||||||
|
license.package_code,
|
||||||
|
license.max_installations
|
||||||
|
FROM identity.client_license license
|
||||||
|
JOIN identity.client_account account
|
||||||
|
ON account.tenant_id = license.tenant_id
|
||||||
|
JOIN identity.client_registration_details registration
|
||||||
|
ON registration.registration_id = account.registration_id
|
||||||
|
WHERE license.tenant_id = $1
|
||||||
|
AND license.license_id = $2
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ReactiveDatabaseClient database;
|
||||||
|
|
||||||
|
public ClientAdministrationRepository(ReactiveDatabaseClient database) {
|
||||||
|
this.database = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> createRegistration(Tuple values) {
|
||||||
|
return database.preparedUpdate(INSERT_REGISTRATION, values)
|
||||||
|
.map(count -> count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> createTenant(
|
||||||
|
UUID tenantId,
|
||||||
|
UUID registrationId,
|
||||||
|
String slug,
|
||||||
|
String name) {
|
||||||
|
return database.preparedUpdate(
|
||||||
|
INSERT_TENANT,
|
||||||
|
Tuple.of(tenantId, registrationId, slug, name))
|
||||||
|
.map(count -> count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> createLicense(
|
||||||
|
UUID licenseId,
|
||||||
|
UUID tenantId,
|
||||||
|
String licenseType,
|
||||||
|
String packageCode,
|
||||||
|
OffsetDateTime validFrom,
|
||||||
|
OffsetDateTime validUntil,
|
||||||
|
Integer maxUsers,
|
||||||
|
int maxInstallations) {
|
||||||
|
return database.preparedUpdate(
|
||||||
|
INSERT_LICENSE,
|
||||||
|
Tuple.of(
|
||||||
|
licenseId,
|
||||||
|
tenantId,
|
||||||
|
licenseType,
|
||||||
|
packageCode,
|
||||||
|
validFrom,
|
||||||
|
validUntil,
|
||||||
|
maxUsers,
|
||||||
|
maxInstallations))
|
||||||
|
.map(count -> count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<LicenseDeliveryDetails> findLicenseDeliveryDetails(
|
||||||
|
UUID tenantId, UUID licenseId) {
|
||||||
|
return database.preparedQuery(
|
||||||
|
FIND_LICENSE_DELIVERY_DETAILS,
|
||||||
|
Tuple.of(tenantId, licenseId))
|
||||||
|
.flatMap(rows -> {
|
||||||
|
var iterator = rows.iterator();
|
||||||
|
if (!iterator.hasNext()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
var row = iterator.next();
|
||||||
|
return Mono.just(new LicenseDeliveryDetails(
|
||||||
|
row.getString("client_code"),
|
||||||
|
row.getString("client_slug"),
|
||||||
|
row.getString("package_code"),
|
||||||
|
row.getInteger("max_installations"),
|
||||||
|
row.getString("primary_contact_email")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package com.cygnus.cloud.tenant.repository;
|
||||||
|
|
||||||
|
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||||
|
import com.cygnus.cloud.tenant.model.ActivationSession;
|
||||||
|
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
|
||||||
|
import io.vertx.sqlclient.Row;
|
||||||
|
import io.vertx.sqlclient.SqlConnection;
|
||||||
|
import io.vertx.sqlclient.Tuple;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class InstallationActivationRepository {
|
||||||
|
|
||||||
|
private static final String INSERT_SESSION = """
|
||||||
|
INSERT INTO identity.installation_activation_session (
|
||||||
|
activation_session_id, activation_key_id, registration_id,
|
||||||
|
tenant_id, license_id, installation_uuid, token_hash,
|
||||||
|
status, expires_at, source_ip, installer_version)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'PENDING', $8,
|
||||||
|
CAST($9 AS text)::inet, $10)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String LOCK_SESSION_AND_LICENSE = """
|
||||||
|
SELECT session.activation_session_id, session.registration_id,
|
||||||
|
session.tenant_id, session.license_id,
|
||||||
|
session.installation_uuid, session.status AS session_status,
|
||||||
|
session.expires_at,
|
||||||
|
registration.client_code, registration.status AS registration_status,
|
||||||
|
account.client_slug, account.status AS tenant_status,
|
||||||
|
license.package_code, license.status AS license_status,
|
||||||
|
license.valid_from, license.valid_until,
|
||||||
|
license.max_installations,
|
||||||
|
(
|
||||||
|
SELECT count(*)::integer
|
||||||
|
FROM identity.client_installation installation
|
||||||
|
WHERE installation.tenant_id = session.tenant_id
|
||||||
|
AND installation.license_id = session.license_id
|
||||||
|
AND installation.status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
|
||||||
|
) AS consuming_installations
|
||||||
|
FROM identity.installation_activation_session session
|
||||||
|
JOIN identity.client_registration_details registration
|
||||||
|
ON registration.registration_id = session.registration_id
|
||||||
|
JOIN identity.client_account account
|
||||||
|
ON account.registration_id = session.registration_id
|
||||||
|
AND account.tenant_id = session.tenant_id
|
||||||
|
JOIN identity.client_license license
|
||||||
|
ON license.tenant_id = session.tenant_id
|
||||||
|
AND license.license_id = session.license_id
|
||||||
|
WHERE session.token_hash = $1
|
||||||
|
FOR UPDATE OF session, license
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String INSERT_INSTALLATION = """
|
||||||
|
INSERT INTO identity.client_installation (
|
||||||
|
installation_id, tenant_id, client_id, installation_code,
|
||||||
|
assertion_public_key, allowed_scopes, enabled,
|
||||||
|
security_version, license_id, installation_uuid,
|
||||||
|
installation_name, status, registered_at,
|
||||||
|
software_version, environment)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, true, 1, $7, $8, $9,
|
||||||
|
'ACTIVE', $10, $11, $12)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String CONSUME_SESSION = """
|
||||||
|
UPDATE identity.installation_activation_session
|
||||||
|
SET status = 'CONSUMED', consumed_at = $2
|
||||||
|
WHERE activation_session_id = $1
|
||||||
|
AND status = 'PENDING'
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String INSERT_AUDIT = """
|
||||||
|
INSERT INTO identity.installation_audit_event (
|
||||||
|
audit_event_id, registration_id, tenant_id, license_id,
|
||||||
|
installation_id, event_type, actor_type, actor_id,
|
||||||
|
reason, event_data, occurred_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'INSTALLATION_REGISTERED',
|
||||||
|
'INSTALLER', $6, 'License-authorized installation',
|
||||||
|
$7::jsonb, $8)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ReactiveDatabaseClient database;
|
||||||
|
|
||||||
|
public InstallationActivationRepository(ReactiveDatabaseClient database) {
|
||||||
|
this.database = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> insertSession(
|
||||||
|
UUID sessionId,
|
||||||
|
UUID activationKeyId,
|
||||||
|
UUID registrationId,
|
||||||
|
UUID tenantId,
|
||||||
|
UUID licenseId,
|
||||||
|
UUID installationUuid,
|
||||||
|
String tokenHash,
|
||||||
|
OffsetDateTime expiresAt,
|
||||||
|
String sourceIp,
|
||||||
|
String installerVersion) {
|
||||||
|
Tuple values = Tuple.tuple()
|
||||||
|
.addUUID(sessionId)
|
||||||
|
.addUUID(activationKeyId)
|
||||||
|
.addUUID(registrationId)
|
||||||
|
.addUUID(tenantId)
|
||||||
|
.addUUID(licenseId)
|
||||||
|
.addUUID(installationUuid)
|
||||||
|
.addString(tokenHash)
|
||||||
|
.addOffsetDateTime(expiresAt)
|
||||||
|
.addString(sourceIp)
|
||||||
|
.addString(installerVersion);
|
||||||
|
return database.preparedUpdate(INSERT_SESSION, values).map(count -> count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<RegisteredInstallation> register(
|
||||||
|
String tokenHash,
|
||||||
|
String installationCode,
|
||||||
|
String installationName,
|
||||||
|
String assertionPublicKey,
|
||||||
|
Set<String> scopes,
|
||||||
|
String softwareVersion,
|
||||||
|
String environment,
|
||||||
|
OffsetDateTime now) {
|
||||||
|
return database.inTransaction(connection -> lockContext(connection, tokenHash)
|
||||||
|
.switchIfEmpty(Mono.error(new IllegalArgumentException(
|
||||||
|
"Activation session is invalid")))
|
||||||
|
.flatMap(context -> validate(context, now))
|
||||||
|
.flatMap(context -> insertInstallation(
|
||||||
|
connection,
|
||||||
|
context,
|
||||||
|
installationCode,
|
||||||
|
installationName,
|
||||||
|
assertionPublicKey,
|
||||||
|
scopes,
|
||||||
|
softwareVersion,
|
||||||
|
environment,
|
||||||
|
now)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<RegistrationContext> lockContext(
|
||||||
|
SqlConnection connection, String tokenHash) {
|
||||||
|
return database.preparedQuery(
|
||||||
|
connection, LOCK_SESSION_AND_LICENSE, Tuple.of(tokenHash))
|
||||||
|
.flatMap(rows -> {
|
||||||
|
java.util.Iterator<Row> iterator = rows.iterator();
|
||||||
|
return iterator.hasNext()
|
||||||
|
? Mono.just(context(iterator.next()))
|
||||||
|
: Mono.empty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<RegistrationContext> validate(
|
||||||
|
RegistrationContext context, OffsetDateTime now) {
|
||||||
|
boolean valid = "PENDING".equals(context.sessionStatus)
|
||||||
|
&& context.expiresAt.isAfter(now)
|
||||||
|
&& "ACTIVE".equals(context.registrationStatus)
|
||||||
|
&& "ACTIVE".equals(context.tenantStatus)
|
||||||
|
&& "ACTIVE".equals(context.licenseStatus)
|
||||||
|
&& !now.isBefore(context.validFrom)
|
||||||
|
&& now.isBefore(context.validUntil)
|
||||||
|
&& context.consumingInstallations < context.maxInstallations;
|
||||||
|
return valid
|
||||||
|
? Mono.just(context)
|
||||||
|
: Mono.error(new IllegalStateException(
|
||||||
|
"Activation, license, or installation capacity is invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<RegisteredInstallation> insertInstallation(
|
||||||
|
SqlConnection connection,
|
||||||
|
RegistrationContext context,
|
||||||
|
String installationCode,
|
||||||
|
String installationName,
|
||||||
|
String assertionPublicKey,
|
||||||
|
Set<String> scopes,
|
||||||
|
String softwareVersion,
|
||||||
|
String environment,
|
||||||
|
OffsetDateTime now) {
|
||||||
|
UUID installationId = UUID.randomUUID();
|
||||||
|
Tuple insert = Tuple.tuple()
|
||||||
|
.addUUID(installationId)
|
||||||
|
.addUUID(context.tenantId)
|
||||||
|
.addString(context.clientSlug)
|
||||||
|
.addString(installationCode)
|
||||||
|
.addString(assertionPublicKey)
|
||||||
|
.addArrayOfString(scopes.toArray(String[]::new))
|
||||||
|
.addUUID(context.licenseId)
|
||||||
|
.addUUID(context.installationUuid)
|
||||||
|
.addString(installationName)
|
||||||
|
.addOffsetDateTime(now)
|
||||||
|
.addString(softwareVersion)
|
||||||
|
.addString(environment);
|
||||||
|
return database.preparedQuery(connection, INSERT_INSTALLATION, insert)
|
||||||
|
.flatMap(rows -> rows.rowCount() == 1
|
||||||
|
? consumeAndAudit(connection, context, installationId, now)
|
||||||
|
: Mono.error(new IllegalStateException(
|
||||||
|
"Installation could not be registered")))
|
||||||
|
.thenReturn(new RegisteredInstallation(
|
||||||
|
installationId,
|
||||||
|
context.installationUuid,
|
||||||
|
context.clientSlug,
|
||||||
|
installationCode,
|
||||||
|
1,
|
||||||
|
"ACTIVE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> consumeAndAudit(
|
||||||
|
SqlConnection connection,
|
||||||
|
RegistrationContext context,
|
||||||
|
UUID installationId,
|
||||||
|
OffsetDateTime now) {
|
||||||
|
return database.preparedQuery(
|
||||||
|
connection,
|
||||||
|
CONSUME_SESSION,
|
||||||
|
Tuple.of(context.sessionId, now))
|
||||||
|
.flatMap(rows -> rows.rowCount() == 1
|
||||||
|
? database.preparedQuery(
|
||||||
|
connection,
|
||||||
|
INSERT_AUDIT,
|
||||||
|
Tuple.of(
|
||||||
|
UUID.randomUUID(),
|
||||||
|
context.registrationId,
|
||||||
|
context.tenantId,
|
||||||
|
context.licenseId,
|
||||||
|
installationId,
|
||||||
|
context.installationUuid.toString(),
|
||||||
|
"{}",
|
||||||
|
now))
|
||||||
|
: Mono.error(new IllegalStateException(
|
||||||
|
"Activation session was already consumed")))
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
private RegistrationContext context(Row row) {
|
||||||
|
return new RegistrationContext(
|
||||||
|
row.getUUID("activation_session_id"),
|
||||||
|
row.getUUID("registration_id"),
|
||||||
|
row.getUUID("tenant_id"),
|
||||||
|
row.getUUID("license_id"),
|
||||||
|
row.getUUID("installation_uuid"),
|
||||||
|
row.getString("session_status"),
|
||||||
|
row.getOffsetDateTime("expires_at"),
|
||||||
|
row.getString("client_code"),
|
||||||
|
row.getString("registration_status"),
|
||||||
|
row.getString("client_slug"),
|
||||||
|
row.getString("tenant_status"),
|
||||||
|
row.getString("package_code"),
|
||||||
|
row.getString("license_status"),
|
||||||
|
row.getOffsetDateTime("valid_from"),
|
||||||
|
row.getOffsetDateTime("valid_until"),
|
||||||
|
row.getInteger("max_installations"),
|
||||||
|
row.getInteger("consuming_installations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RegistrationContext(
|
||||||
|
UUID sessionId,
|
||||||
|
UUID registrationId,
|
||||||
|
UUID tenantId,
|
||||||
|
UUID licenseId,
|
||||||
|
UUID installationUuid,
|
||||||
|
String sessionStatus,
|
||||||
|
OffsetDateTime expiresAt,
|
||||||
|
String clientCode,
|
||||||
|
String registrationStatus,
|
||||||
|
String clientSlug,
|
||||||
|
String tenantStatus,
|
||||||
|
String packageCode,
|
||||||
|
String licenseStatus,
|
||||||
|
OffsetDateTime validFrom,
|
||||||
|
OffsetDateTime validUntil,
|
||||||
|
int maxInstallations,
|
||||||
|
int consumingInstallations) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.cygnus.cloud.tenant.repository;
|
||||||
|
|
||||||
|
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||||
|
import com.cygnus.cloud.tenant.model.InstallationLifecycleResult;
|
||||||
|
import io.vertx.sqlclient.Tuple;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class InstallationLifecycleRepository {
|
||||||
|
|
||||||
|
private static final String RETIRE = """
|
||||||
|
WITH retired AS (
|
||||||
|
UPDATE identity.client_installation
|
||||||
|
SET status = $3,
|
||||||
|
enabled = false,
|
||||||
|
security_version = security_version + 1,
|
||||||
|
retired_at = now(),
|
||||||
|
retired_by = $4,
|
||||||
|
retirement_reason = $5
|
||||||
|
WHERE installation_id = $1
|
||||||
|
AND tenant_id = $2
|
||||||
|
AND status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
|
||||||
|
RETURNING installation_id, tenant_id, client_id,
|
||||||
|
installation_code, license_id, status
|
||||||
|
), audited AS (
|
||||||
|
INSERT INTO identity.installation_audit_event (
|
||||||
|
audit_event_id, registration_id, tenant_id, license_id,
|
||||||
|
installation_id, event_type, actor_type, actor_id,
|
||||||
|
reason, event_data)
|
||||||
|
SELECT gen_random_uuid(), account.registration_id, retired.tenant_id,
|
||||||
|
retired.license_id, retired.installation_id, retired.status,
|
||||||
|
'ADMIN', $4, $5, '{}'::jsonb
|
||||||
|
FROM retired
|
||||||
|
JOIN identity.client_account account
|
||||||
|
ON account.tenant_id = retired.tenant_id
|
||||||
|
)
|
||||||
|
SELECT installation_id, tenant_id, client_id, installation_code, status
|
||||||
|
FROM retired
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ReactiveDatabaseClient database;
|
||||||
|
|
||||||
|
public InstallationLifecycleRepository(ReactiveDatabaseClient database) {
|
||||||
|
this.database = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<InstallationLifecycleResult> retire(
|
||||||
|
UUID tenantId,
|
||||||
|
UUID installationId,
|
||||||
|
String status,
|
||||||
|
String actor,
|
||||||
|
String reason) {
|
||||||
|
return database.preparedQuery(
|
||||||
|
RETIRE,
|
||||||
|
Tuple.of(installationId, tenantId, status, actor, reason))
|
||||||
|
.flatMap(rows -> {
|
||||||
|
var iterator = rows.iterator();
|
||||||
|
if (!iterator.hasNext()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
var row = iterator.next();
|
||||||
|
return Mono.just(new InstallationLifecycleResult(
|
||||||
|
row.getUUID("installation_id"),
|
||||||
|
row.getUUID("tenant_id"),
|
||||||
|
row.getString("client_id"),
|
||||||
|
row.getString("installation_code"),
|
||||||
|
row.getString("status")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.cygnus.cloud.tenant.repository;
|
||||||
|
|
||||||
|
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||||
|
import com.cygnus.cloud.tenant.model.ActivationKeyStatus;
|
||||||
|
import com.cygnus.cloud.tenant.model.ClientStatus;
|
||||||
|
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
|
||||||
|
import com.cygnus.cloud.tenant.model.LicenseActivationKey;
|
||||||
|
import com.cygnus.cloud.tenant.model.LicenseStatus;
|
||||||
|
import com.cygnus.cloud.tenant.model.RegistrationStatus;
|
||||||
|
import io.vertx.sqlclient.Row;
|
||||||
|
import io.vertx.sqlclient.Tuple;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class LicenseActivationRepository {
|
||||||
|
|
||||||
|
private static final String FIND_CANDIDATES = """
|
||||||
|
SELECT activation.activation_key_id, activation.registration_id,
|
||||||
|
activation.tenant_id, activation.license_id,
|
||||||
|
activation.key_hash, activation.key_hint, activation.status,
|
||||||
|
activation.expires_at, activation.failed_attempts,
|
||||||
|
activation.maximum_attempts, activation.locked_until,
|
||||||
|
activation.created_at, activation.last_used_at,
|
||||||
|
registration.client_code,
|
||||||
|
registration.status AS registration_status,
|
||||||
|
account.status AS tenant_status,
|
||||||
|
account.client_slug, account.client_name,
|
||||||
|
license.package_code, license.license_type,
|
||||||
|
license.status AS license_status,
|
||||||
|
license.valid_from, license.valid_until,
|
||||||
|
license.max_installations,
|
||||||
|
(
|
||||||
|
SELECT count(*)::integer
|
||||||
|
FROM identity.client_installation installation
|
||||||
|
WHERE installation.tenant_id = activation.tenant_id
|
||||||
|
AND installation.license_id = activation.license_id
|
||||||
|
AND installation.status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
|
||||||
|
) AS consuming_installations
|
||||||
|
FROM identity.license_activation_key activation
|
||||||
|
JOIN identity.client_registration_details registration
|
||||||
|
ON registration.registration_id = activation.registration_id
|
||||||
|
JOIN identity.client_account account
|
||||||
|
ON account.registration_id = activation.registration_id
|
||||||
|
AND account.tenant_id = activation.tenant_id
|
||||||
|
JOIN identity.client_license license
|
||||||
|
ON license.tenant_id = activation.tenant_id
|
||||||
|
AND license.license_id = activation.license_id
|
||||||
|
WHERE upper(registration.client_code) = upper($1)
|
||||||
|
AND activation.key_hint = $2
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String INSERT_KEY = """
|
||||||
|
INSERT INTO identity.license_activation_key (
|
||||||
|
activation_key_id, registration_id, tenant_id, license_id,
|
||||||
|
key_hash, key_hint, status, expires_at, created_by)
|
||||||
|
SELECT $1, account.registration_id, account.tenant_id,
|
||||||
|
license.license_id, $4, $5, 'ACTIVE', $6, $7
|
||||||
|
FROM identity.client_account account
|
||||||
|
JOIN identity.client_license license
|
||||||
|
ON license.tenant_id = account.tenant_id
|
||||||
|
WHERE account.tenant_id = $2
|
||||||
|
AND license.license_id = $3
|
||||||
|
AND account.status = 'ACTIVE'
|
||||||
|
AND license.status = 'ACTIVE'
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String RECORD_SUCCESS = """
|
||||||
|
UPDATE identity.license_activation_key
|
||||||
|
SET failed_attempts = 0,
|
||||||
|
locked_until = NULL,
|
||||||
|
status = 'ACTIVE',
|
||||||
|
last_used_at = $2
|
||||||
|
WHERE activation_key_id = $1
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String RECORD_FAILURE = """
|
||||||
|
UPDATE identity.license_activation_key
|
||||||
|
SET failed_attempts = LEAST(failed_attempts + 1, maximum_attempts),
|
||||||
|
status = CASE
|
||||||
|
WHEN failed_attempts + 1 >= maximum_attempts THEN 'LOCKED'
|
||||||
|
ELSE status
|
||||||
|
END,
|
||||||
|
locked_until = CASE
|
||||||
|
WHEN failed_attempts + 1 >= maximum_attempts THEN $2
|
||||||
|
ELSE locked_until
|
||||||
|
END
|
||||||
|
WHERE activation_key_id = $1
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String REVOKE_KEY = """
|
||||||
|
UPDATE identity.license_activation_key
|
||||||
|
SET status = 'REVOKED',
|
||||||
|
locked_until = NULL,
|
||||||
|
revoked_at = now(),
|
||||||
|
revoked_by = 'system',
|
||||||
|
revocation_reason = 'Activation-key delivery failed'
|
||||||
|
WHERE activation_key_id = $1
|
||||||
|
AND status IN ('ACTIVE', 'LOCKED')
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ReactiveDatabaseClient database;
|
||||||
|
|
||||||
|
public LicenseActivationRepository(ReactiveDatabaseClient database) {
|
||||||
|
this.database = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<LicenseActivationContext> findCandidates(
|
||||||
|
String clientCode, String keyHint) {
|
||||||
|
return database.preparedQuery(FIND_CANDIDATES, Tuple.of(clientCode, keyHint))
|
||||||
|
.flatMapMany(Flux::fromIterable)
|
||||||
|
.map(this::context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> insert(
|
||||||
|
UUID activationKeyId,
|
||||||
|
UUID tenantId,
|
||||||
|
UUID licenseId,
|
||||||
|
String keyHash,
|
||||||
|
String keyHint,
|
||||||
|
OffsetDateTime expiresAt,
|
||||||
|
String createdBy) {
|
||||||
|
Tuple values = Tuple.tuple()
|
||||||
|
.addUUID(activationKeyId)
|
||||||
|
.addUUID(tenantId)
|
||||||
|
.addUUID(licenseId)
|
||||||
|
.addString(keyHash)
|
||||||
|
.addString(keyHint)
|
||||||
|
.addOffsetDateTime(expiresAt)
|
||||||
|
.addString(createdBy);
|
||||||
|
return database.preparedUpdate(INSERT_KEY, values).map(count -> count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> recordSuccess(UUID activationKeyId, OffsetDateTime now) {
|
||||||
|
return database.preparedUpdate(
|
||||||
|
RECORD_SUCCESS, Tuple.of(activationKeyId, now))
|
||||||
|
.map(count -> count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> recordFailure(
|
||||||
|
UUID activationKeyId, OffsetDateTime lockedUntil) {
|
||||||
|
return database.preparedUpdate(
|
||||||
|
RECORD_FAILURE, Tuple.of(activationKeyId, lockedUntil))
|
||||||
|
.map(count -> count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> revoke(UUID activationKeyId) {
|
||||||
|
return database.preparedUpdate(REVOKE_KEY, Tuple.of(activationKeyId))
|
||||||
|
.map(count -> count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LicenseActivationContext context(Row row) {
|
||||||
|
LicenseActivationKey key = new LicenseActivationKey(
|
||||||
|
row.getUUID("activation_key_id"),
|
||||||
|
row.getUUID("registration_id"),
|
||||||
|
row.getUUID("tenant_id"),
|
||||||
|
row.getUUID("license_id"),
|
||||||
|
row.getString("key_hash"),
|
||||||
|
row.getString("key_hint"),
|
||||||
|
ActivationKeyStatus.valueOf(row.getString("status")),
|
||||||
|
row.getOffsetDateTime("expires_at"),
|
||||||
|
row.getInteger("failed_attempts"),
|
||||||
|
row.getInteger("maximum_attempts"),
|
||||||
|
row.getOffsetDateTime("locked_until"),
|
||||||
|
row.getOffsetDateTime("created_at"),
|
||||||
|
row.getOffsetDateTime("last_used_at"));
|
||||||
|
return new LicenseActivationContext(
|
||||||
|
key,
|
||||||
|
row.getString("client_code"),
|
||||||
|
RegistrationStatus.valueOf(row.getString("registration_status")),
|
||||||
|
ClientStatus.valueOf(row.getString("tenant_status")),
|
||||||
|
row.getString("client_slug"),
|
||||||
|
row.getString("client_name"),
|
||||||
|
row.getString("package_code"),
|
||||||
|
row.getString("license_type"),
|
||||||
|
LicenseStatus.valueOf(row.getString("license_status")),
|
||||||
|
row.getOffsetDateTime("valid_from").toInstant(),
|
||||||
|
row.getOffsetDateTime("valid_until").toInstant(),
|
||||||
|
row.getInteger("max_installations"),
|
||||||
|
row.getInteger("consuming_installations"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.cygnus.cloud.tenant.repository;
|
||||||
|
|
||||||
|
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||||
|
import com.cygnus.cloud.tenant.model.ClientAccount;
|
||||||
|
import com.cygnus.cloud.tenant.model.ClientInstallation;
|
||||||
|
import com.cygnus.cloud.tenant.model.ClientLicense;
|
||||||
|
import com.cygnus.cloud.tenant.model.ClientStatus;
|
||||||
|
import com.cygnus.cloud.tenant.model.LicenseStatus;
|
||||||
|
import io.vertx.sqlclient.Row;
|
||||||
|
import io.vertx.sqlclient.Tuple;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class TenantRegistrationRepository {
|
||||||
|
|
||||||
|
private static final String FIND_ACCOUNT_BY_SLUG = """
|
||||||
|
SELECT tenant_id, client_slug, client_name, status, created_at, updated_at
|
||||||
|
FROM identity.client_account
|
||||||
|
WHERE client_slug = $1
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String FIND_INSTALLATION = """
|
||||||
|
SELECT installation.installation_id, installation.tenant_id,
|
||||||
|
installation.client_id, installation.installation_code,
|
||||||
|
installation.assertion_public_key, installation.allowed_scopes,
|
||||||
|
installation.enabled, installation.security_version,
|
||||||
|
installation.last_authenticated_at
|
||||||
|
FROM identity.client_installation installation
|
||||||
|
JOIN identity.client_account account
|
||||||
|
ON account.tenant_id = installation.tenant_id
|
||||||
|
WHERE installation.client_id = $1
|
||||||
|
AND installation.installation_code = $2
|
||||||
|
AND installation.enabled = true
|
||||||
|
AND installation.status = 'ACTIVE'
|
||||||
|
AND account.status = 'ACTIVE'
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String TOUCH_INSTALLATION = """
|
||||||
|
UPDATE identity.client_installation
|
||||||
|
SET last_seen_at = $2,
|
||||||
|
last_authenticated_at = $2
|
||||||
|
WHERE installation_id = $1
|
||||||
|
AND (last_seen_at IS NULL OR last_seen_at < $2 - INTERVAL '5 minutes')
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String FIND_CURRENT_LICENSE = """
|
||||||
|
SELECT license_id, tenant_id, license_type, package_code,
|
||||||
|
valid_from, valid_until, status, max_users, max_installations,
|
||||||
|
updated_at
|
||||||
|
FROM identity.client_license
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND valid_from <= $2
|
||||||
|
AND valid_until > $2
|
||||||
|
ORDER BY
|
||||||
|
CASE status
|
||||||
|
WHEN 'ACTIVE' THEN 0
|
||||||
|
WHEN 'SUSPENDED' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
valid_until DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ReactiveDatabaseClient database;
|
||||||
|
|
||||||
|
public TenantRegistrationRepository(ReactiveDatabaseClient database) {
|
||||||
|
this.database = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<ClientAccount> findAccountBySlug(String clientSlug) {
|
||||||
|
return database.preparedQuery(FIND_ACCOUNT_BY_SLUG, Tuple.of(clientSlug))
|
||||||
|
.flatMap(rows -> first(rows, this::account));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<ClientInstallation> findInstallation(
|
||||||
|
String clientId, String installationCode) {
|
||||||
|
return database.preparedQuery(
|
||||||
|
FIND_INSTALLATION, Tuple.of(clientId, installationCode))
|
||||||
|
.flatMap(rows -> first(rows, this::installation));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<ClientLicense> findCurrentLicense(UUID tenantId, Instant instant) {
|
||||||
|
return database.preparedQuery(
|
||||||
|
FIND_CURRENT_LICENSE, Tuple.of(tenantId, instant.atOffset(java.time.ZoneOffset.UTC)))
|
||||||
|
.flatMap(rows -> first(rows, this::license));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> touchInstallation(UUID installationId, Instant instant) {
|
||||||
|
return database.preparedUpdate(
|
||||||
|
TOUCH_INSTALLATION,
|
||||||
|
Tuple.of(
|
||||||
|
installationId,
|
||||||
|
instant.atOffset(java.time.ZoneOffset.UTC)))
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> Mono<T> first(
|
||||||
|
Iterable<Row> rows, java.util.function.Function<Row, T> mapper) {
|
||||||
|
java.util.Iterator<Row> iterator = rows.iterator();
|
||||||
|
return iterator.hasNext() ? Mono.just(mapper.apply(iterator.next())) : Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClientAccount account(Row row) {
|
||||||
|
return new ClientAccount(
|
||||||
|
row.getUUID("tenant_id"),
|
||||||
|
row.getString("client_slug"),
|
||||||
|
row.getString("client_name"),
|
||||||
|
ClientStatus.valueOf(row.getString("status")),
|
||||||
|
row.getOffsetDateTime("created_at"),
|
||||||
|
row.getOffsetDateTime("updated_at"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClientInstallation installation(Row row) {
|
||||||
|
String[] scopes = row.getArrayOfStrings("allowed_scopes");
|
||||||
|
return new ClientInstallation(
|
||||||
|
row.getUUID("installation_id"),
|
||||||
|
row.getUUID("tenant_id"),
|
||||||
|
row.getString("client_id"),
|
||||||
|
row.getString("installation_code"),
|
||||||
|
row.getString("assertion_public_key"),
|
||||||
|
scopes == null
|
||||||
|
? Set.of()
|
||||||
|
: java.util.Collections.unmodifiableSet(
|
||||||
|
new LinkedHashSet<>(Arrays.asList(scopes))),
|
||||||
|
row.getBoolean("enabled"),
|
||||||
|
row.getInteger("security_version"),
|
||||||
|
row.getOffsetDateTime("last_authenticated_at"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClientLicense license(Row row) {
|
||||||
|
return new ClientLicense(
|
||||||
|
row.getUUID("license_id"),
|
||||||
|
row.getUUID("tenant_id"),
|
||||||
|
row.getString("license_type"),
|
||||||
|
row.getString("package_code"),
|
||||||
|
row.getOffsetDateTime("valid_from").toInstant(),
|
||||||
|
row.getOffsetDateTime("valid_until").toInstant(),
|
||||||
|
LicenseStatus.valueOf(row.getString("status")),
|
||||||
|
row.getInteger("max_users"),
|
||||||
|
row.getInteger("max_installations"),
|
||||||
|
row.getOffsetDateTime("updated_at"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.cygnus.cloud.tenant.service;
|
||||||
|
|
||||||
|
public class ActivationRateLimitException extends RuntimeException {
|
||||||
|
public ActivationRateLimitException() {
|
||||||
|
super("Installation activation rate limit exceeded");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.cygnus.cloud.tenant.service;
|
||||||
|
|
||||||
|
import com.cygnus.cloud.cache.ReactiveCacheService;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ActivationRateLimiter {
|
||||||
|
|
||||||
|
private static final String NAMESPACE = "installation-activation-rate";
|
||||||
|
private final ReactiveCacheService cache;
|
||||||
|
private final int maximumAttempts;
|
||||||
|
private final Duration window;
|
||||||
|
|
||||||
|
public ActivationRateLimiter(
|
||||||
|
ReactiveCacheService cache,
|
||||||
|
@Value("${cygnus.activation-rate-limit.maximum-attempts:10}")
|
||||||
|
int maximumAttempts,
|
||||||
|
@Value("${cygnus.activation-rate-limit.window:10m}")
|
||||||
|
Duration window) {
|
||||||
|
this.cache = cache;
|
||||||
|
this.maximumAttempts = maximumAttempts;
|
||||||
|
this.window = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> check(String sourceIp, String clientCode) {
|
||||||
|
String identity = (sourceIp == null ? "unknown" : sourceIp)
|
||||||
|
+ '|'
|
||||||
|
+ clientCode.toUpperCase(java.util.Locale.ROOT);
|
||||||
|
return cache.increment(NAMESPACE, digest(identity), window)
|
||||||
|
.flatMap(attempts -> attempts <= maximumAttempts
|
||||||
|
? Mono.empty()
|
||||||
|
: Mono.error(new ActivationRateLimitException()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String digest(String value) {
|
||||||
|
try {
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user