19 Commits

Author SHA1 Message Date
5b8280cbc3 Merge branch 'project-installer-license-workflow' into development 2026-07-26 23:43:30 +05:30
b3836639bf removed further class files 2026-07-26 23:42:25 +05:30
6f1fb56670 remove classes folder 2026-07-26 23:39:32 +05:30
8ef8bf5d92 Docker installer workflow and licensing flow done - docker container is working fine 2026-07-26 23:38:05 +05:30
d684931bc5 Installer workflow is done - Docker container is working fine 2026-07-26 23:37:23 +05:30
3cf5c83264 Update .gitignore 2026-07-26 20:29:13 +05:30
ebeaaa2629 git ignore added 2026-07-26 20:28:42 +05:30
b84dbc9a56 Update .gitignore 2026-07-26 20:27:56 +05:30
1817d02c31 commit 2026-07-26 19:51:23 +05:30
4afe00e1f8 Installed GUI Integration 2026-07-26 19:48:26 +05:30
60f5450f47 Installation automation - License key approach 2026-07-26 17:02:16 +05:30
d6dc33d9b1 Multi tenant approach - cleanup done 2026-07-26 16:13:47 +05:30
a4daf7e204 Machine Installation id based Oauth 2 2026-07-26 15:19:27 +05:30
dcb40473da Performance tuning done 2026-07-23 11:53:40 +05:30
4cbd510b85 Index to increase the performance 2026-07-21 22:07:45 +05:30
f0481d090c Performance tuning - backend 2026-07-21 21:32:53 +05:30
4f7c06ed97 UI Migration Done 2026-07-21 20:46:27 +05:30
46183766e5 Maximum UI changes fixed 2026-07-20 01:34:18 +05:30
b6c9ee4bf8 Maven and JDK 21 Migration 2026-07-16 22:35:34 +05:30
1280 changed files with 436214 additions and 35013 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

10
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/target/
/config/
!.mvn/wrapper/maven-wrapper.jar
### STS ###
@@ -18,11 +19,16 @@
### NetBeans ###
/nbproject/private/
/build/
/build/classes/*
!/build/WebContent/
!/build/WebContent/**
/build/WebContent/ReportsBackup/
/build/WebContent/WEB-INF/lib/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
/cygnus-installer/target
/cygnus-onprem-app/target
/cygnus-cloud-client/target
/cygnus-installer/src/test
/cygnus-cloud-service/target

83
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,83 @@
{
"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_SSL": "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}/matrix-installation/config/machine-assertion.jwt",
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
"CYGNUS_LOGIN_PUBLIC_KEY": "file:${workspaceFolder}/matrix-installation/config/keys/login-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
View File

@@ -0,0 +1,4 @@
{
"java.configuration.updateBuildConfiguration": "automatic",
"java.compile.nullAnalysis.mode": "automatic"
}

24
.vscode/tasks.json vendored Normal file
View 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"
]
}
]
}

View 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

Binary file not shown.

186
README.md
View File

@@ -1,93 +1,93 @@
# Matrix
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://12.168.10.68/nimblev1/matrix.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](http://12.168.10.68/nimblev1/matrix/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
# Matrix
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://12.168.10.68/nimblev1/matrix.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](http://12.168.10.68/nimblev1/matrix/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

View 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.

View 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>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,53 @@
package com.cygnus.client;
import com.cygnus.client.model.CloudIdentitySession;
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());
}
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -0,0 +1,8 @@
package com.cygnus.client.model;
public record EncryptedLoginRequest(
String keyId,
String encryptedKey,
String initializationVector,
String encryptedPayload) {
}

View File

@@ -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) {
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,7 @@
package com.cygnus.client.security;
import reactor.core.publisher.Mono;
public interface MachineTokenProvider {
Mono<String> accessToken();
}

View File

@@ -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) {
}
}

View File

@@ -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));
}
}

View 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.

View 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>

View File

@@ -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);
}
}

View 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) {
}

View 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;
}
}

View File

@@ -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) {
}

View File

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

View File

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

View File

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

View File

@@ -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) {
}

View File

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

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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;
}
}
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.identity.service;
public class AuthenticationException extends RuntimeException {
public AuthenticationException(String message) {
super(message);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -0,0 +1,57 @@
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/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;
}
}

View File

@@ -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) {
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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) {
}

View File

@@ -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);
}
}

View File

@@ -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+")));
}
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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)));
}
}

View File

@@ -0,0 +1,6 @@
package com.cygnus.cloud.system;
import java.time.Instant;
public record SystemInfoResponse(String service, String version, Instant timestamp) {
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

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

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

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

View File

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

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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) {}

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public enum ActivationKeyStatus {
ACTIVE,
LOCKED,
REVOKED,
EXPIRED
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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);
}
}

View File

@@ -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) {
}

View File

@@ -0,0 +1,7 @@
package com.cygnus.cloud.tenant.model;
public enum ClientStatus {
ACTIVE,
SUSPENDED,
CANCELLED
}

View File

@@ -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) {}

View File

@@ -0,0 +1,9 @@
package com.cygnus.cloud.tenant.model;
import java.util.UUID;
public record IssuedLicenseKey(
UUID activationKeyId,
String licenseKey,
String keyHint) {
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public record LicenseDeliveryDetails(
String clientCode,
String tenantSlug,
String packageCode,
int maximumInstallations,
String primaryContactEmail) {}

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public enum LicenseStatus {
ACTIVE,
SUSPENDED,
EXPIRED,
CANCELLED
}

View File

@@ -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) {
}

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public enum RegistrationStatus {
DRAFT,
ACTIVE,
SUSPENDED,
TERMINATED
}

View File

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

View File

@@ -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) {
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.cygnus.cloud.tenant.service;
public class ActivationRateLimitException extends RuntimeException {
public ActivationRateLimitException() {
super("Installation activation rate limit exceeded");
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.cloud.tenant.service;
public class InstallationActivationException extends RuntimeException {
public InstallationActivationException(String message) {
super(message);
}
public InstallationActivationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,179 @@
package com.cygnus.cloud.tenant.service;
import com.cygnus.cloud.tenant.model.ActivationSession;
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
import com.cygnus.cloud.tenant.repository.InstallationActivationRepository;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.Clock;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Set;
import java.util.UUID;
import io.vertx.pgclient.PgException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class InstallationActivationService {
private static final Duration SESSION_TTL = Duration.ofMinutes(20);
private static final Set<String> INSTALLATION_SCOPES = Set.of("identity.login");
private final LicenseKeyService licenseKeyService;
private final InstallationActivationRepository repository;
private final SecureRandom random;
private final Clock clock;
@Autowired
public InstallationActivationService(
LicenseKeyService licenseKeyService,
InstallationActivationRepository repository,
Clock clock) {
this(licenseKeyService, repository, clock, new SecureRandom());
}
InstallationActivationService(
LicenseKeyService licenseKeyService,
InstallationActivationRepository repository,
Clock clock,
SecureRandom random) {
this.licenseKeyService = licenseKeyService;
this.repository = repository;
this.clock = clock;
this.random = random;
}
public Mono<ActivationSession> validateAndCreateSession(
String clientCode,
String licenseKey,
UUID installationUuid,
String sourceIp,
String installerVersion) {
return licenseKeyService.validate(clientCode, licenseKey)
.flatMap(context -> createSession(
context,
installationUuid,
sourceIp,
installerVersion));
}
public Mono<RegisteredInstallation> register(
String activationToken,
String installationCode,
String installationName,
String assertionPublicKey,
String softwareVersion,
String environment) {
validatePublicKey(assertionPublicKey);
OffsetDateTime now = now();
return repository.register(
digest(activationToken),
installationCode,
installationName,
assertionPublicKey,
INSTALLATION_SCOPES,
softwareVersion,
environment,
now)
.onErrorMap(
error -> error instanceof PgException pgException
&& "23505".equals(pgException.getSqlState()),
error -> new InstallationCodeConflictException(
installationCode))
.onErrorMap(
error -> !(error instanceof InstallationActivationException),
error -> new InstallationActivationException(
"Installation registration failed", error));
}
private Mono<ActivationSession> createSession(
LicenseActivationContext context,
UUID installationUuid,
String sourceIp,
String installerVersion) {
String token = randomToken();
UUID sessionId = UUID.randomUUID();
OffsetDateTime expiresAt = now().plus(SESSION_TTL);
return repository.insertSession(
sessionId,
context.activationKey().activationKeyId(),
context.activationKey().registrationId(),
context.activationKey().tenantId(),
context.activationKey().licenseId(),
installationUuid,
digest(token),
expiresAt,
sourceIp,
installerVersion)
.flatMap(inserted -> inserted
? Mono.just(new ActivationSession(
sessionId,
context.activationKey().registrationId(),
context.activationKey().tenantId(),
context.activationKey().licenseId(),
installationUuid,
token,
expiresAt,
context.clientCode(),
context.clientSlug(),
context.packageCode(),
context.maxInstallations()))
: Mono.error(new InstallationActivationException(
"Activation session could not be created")));
}
private String randomToken() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String digest(String value) {
if (value == null || value.isBlank()) {
throw new InstallationActivationException(
"Activation token is required");
}
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (Exception exception) {
throw new InstallationActivationException(
"Activation token could not be processed", exception);
}
}
private void validatePublicKey(String pem) {
try {
String encoded = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
RSAPublicKey key = (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
if (key.getModulus().bitLength() < 3072) {
throw new InstallationActivationException(
"Installation public key must be at least 3072-bit RSA");
}
} catch (InstallationActivationException exception) {
throw exception;
} catch (Exception exception) {
throw new InstallationActivationException(
"Installation public key is invalid");
}
}
private OffsetDateTime now() {
return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
}
}

View File

@@ -0,0 +1,9 @@
package com.cygnus.cloud.tenant.service;
public class InstallationCodeConflictException
extends InstallationActivationException {
public InstallationCodeConflictException(String installationCode) {
super("Installation code already exists: " + installationCode);
}
}

View File

@@ -0,0 +1,35 @@
package com.cygnus.cloud.tenant.service;
import com.cygnus.cloud.tenant.model.InstallationLifecycleResult;
import com.cygnus.cloud.tenant.repository.InstallationLifecycleRepository;
import java.util.UUID;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class InstallationLifecycleService {
private final InstallationLifecycleRepository repository;
private final TenantRegistrationService registrations;
public InstallationLifecycleService(
InstallationLifecycleRepository repository,
TenantRegistrationService registrations) {
this.repository = repository;
this.registrations = registrations;
}
public Mono<InstallationLifecycleResult> retire(
UUID tenantId,
UUID installationId,
String status,
String actor,
String reason) {
return repository.retire(tenantId, installationId, status, actor, reason)
.switchIfEmpty(Mono.error(
new IllegalArgumentException("Active installation not found")))
.flatMap(result -> registrations
.evictInstallation(result.clientId(), result.installationCode())
.thenReturn(result));
}
}

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.service;
public class LicenseKeyException extends RuntimeException {
public LicenseKeyException(String message) {
super(message);
}
}

Some files were not shown because too many files have changed in this diff Show More