Compare commits
10 Commits
main
...
a0468d2e6a
| Author | SHA1 | Date | |
|---|---|---|---|
| a0468d2e6a | |||
| c927de82ef | |||
| a4daf7e204 | |||
| dcb40473da | |||
| 4cbd510b85 | |||
| f0481d090c | |||
| 4f7c06ed97 | |||
| 46183766e5 | |||
| b6c9ee4bf8 | |||
| 0080857d4e |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
/target/
|
||||
/config/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
### STS ###
|
||||
@@ -18,8 +19,12 @@
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/build/
|
||||
/build/classes/*
|
||||
!/build/WebContent/
|
||||
!/build/WebContent/**
|
||||
/build/WebContent/ReportsBackup/
|
||||
/build/WebContent/WEB-INF/lib/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
/.nb-gradle/
|
||||
|
||||
77
.vscode/launch.json
vendored
Normal file
77
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"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",
|
||||
"SPRING_CONFIG_ADDITIONAL_LOCATION": "file:${workspaceFolder}/config/clients.yml"
|
||||
},
|
||||
"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}/config/clients/matrix/matrix-matrix-delhi-cygnus-01-assertion.jwt",
|
||||
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
|
||||
"CYGNUS_LOGIN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/login-public.pem",
|
||||
"CYGNUS_CLOUD_REQUEST_TIMEOUT": "PT10S",
|
||||
"CYGNUS_TOKEN_REFRESH_SKEW": "PT30S"
|
||||
},
|
||||
"vmArgs": "-Dserver.port=8080 -Djava.awt.headless=true -Dmatrix.webapp=${workspaceFolder}/cygnus-onprem-app/build/WebContent -Dmatrix.classes=${workspaceFolder}/cygnus-onprem-app/target/classes",
|
||||
"shortenCommandLine": "argfile"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "Current Java File",
|
||||
"request": "launch",
|
||||
"mainClass": "${file}"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic",
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
}
|
||||
24
.vscode/tasks.json
vendored
Normal file
24
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "cygnus: dev compile",
|
||||
"type": "shell",
|
||||
"command": "mvn",
|
||||
"args": [
|
||||
"test-compile",
|
||||
"-DskipTests"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home"
|
||||
}
|
||||
},
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
"$javac"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
359
Cygnus_Architecture_Overview.svg
Normal file
359
Cygnus_Architecture_Overview.svg
Normal file
@@ -0,0 +1,359 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="3720" viewBox="0 0 1920 3720" role="img" aria-labelledby="title description">
|
||||
<title id="title">Cygnus on-premises to cloud authentication architecture overview</title>
|
||||
<desc id="description">Complete architecture, request flow, sequence diagram, security layers, code navigation, data access, session mapping, and failure behavior for Cygnus cloud identity authentication.</desc>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="pageBg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#eaf2f8"/>
|
||||
<stop offset="1" stop-color="#d9e7f1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="heroBg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#173b5f"/>
|
||||
<stop offset="1" stop-color="#28679b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cloudBg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#e7f2fb"/>
|
||||
<stop offset="1" stop-color="#d2e7f7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="onpremBg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#eef8f2"/>
|
||||
<stop offset="1" stop-color="#dcefe3"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="5" stdDeviation="8" flood-color="#1c3448" flood-opacity=".16"/>
|
||||
</filter>
|
||||
<marker id="arrowBlue" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L12,6 L0,12 z" fill="#28679b"/>
|
||||
</marker>
|
||||
<marker id="arrowGreen" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L12,6 L0,12 z" fill="#26845b"/>
|
||||
</marker>
|
||||
<marker id="arrowOrange" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L12,6 L0,12 z" fill="#d47b17"/>
|
||||
</marker>
|
||||
<style>
|
||||
text { font-family: Inter, "Segoe UI", Arial, sans-serif; fill: #22303d; }
|
||||
.hero-title { font-size: 46px; font-weight: 750; fill: #fff; letter-spacing: .2px; }
|
||||
.hero-sub { font-size: 22px; fill: #d7eafb; }
|
||||
.section-title { font-size: 30px; font-weight: 750; fill: #173b5f; }
|
||||
.section-sub { font-size: 17px; fill: #536777; }
|
||||
.box-title { font-size: 20px; font-weight: 750; }
|
||||
.box-sub { font-size: 14px; fill: #5a6f7f; }
|
||||
.body { font-size: 16px; }
|
||||
.small { font-size: 14px; }
|
||||
.tiny { font-size: 12px; }
|
||||
.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 13px; }
|
||||
.white { fill: #fff; }
|
||||
.muted { fill: #64798a; }
|
||||
.blue { fill: #28679b; }
|
||||
.green { fill: #26845b; }
|
||||
.orange { fill: #c76c0f; }
|
||||
.red { fill: #b74646; }
|
||||
.bold { font-weight: 700; }
|
||||
.card { fill: #fff; stroke: #b8cad8; stroke-width: 1.4; rx: 12; filter: url(#shadow); }
|
||||
.soft-card { fill: #f7fafc; stroke: #c5d5e1; stroke-width: 1.2; rx: 10; }
|
||||
.onprem { fill: url(#onpremBg); stroke: #83b99b; stroke-width: 1.5; rx: 12; }
|
||||
.cloud { fill: url(#cloudBg); stroke: #81b2d5; stroke-width: 1.5; rx: 12; }
|
||||
.security { fill: #fff6e7; stroke: #e2ae62; stroke-width: 1.4; rx: 10; }
|
||||
.database { fill: #f2ecfa; stroke: #a991c6; stroke-width: 1.4; rx: 10; }
|
||||
.line-blue { stroke: #28679b; stroke-width: 3; fill: none; marker-end: url(#arrowBlue); }
|
||||
.line-green { stroke: #26845b; stroke-width: 3; fill: none; marker-end: url(#arrowGreen); }
|
||||
.line-orange { stroke: #d47b17; stroke-width: 3; fill: none; marker-end: url(#arrowOrange); }
|
||||
.dash { stroke-dasharray: 8 7; }
|
||||
.lane { stroke: #9bb0bf; stroke-width: 1.5; stroke-dasharray: 6 6; }
|
||||
.step-circle { fill: #28679b; stroke: #fff; stroke-width: 3; }
|
||||
.step-number { font-size: 14px; font-weight: 750; fill: #fff; text-anchor: middle; dominant-baseline: central; }
|
||||
.chip { fill: #e5eff7; stroke: #aec4d5; stroke-width: 1; rx: 14; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1920" height="3720" fill="url(#pageBg)"/>
|
||||
|
||||
<!-- Hero -->
|
||||
<rect x="50" y="42" width="1820" height="190" rx="20" fill="url(#heroBg)" filter="url(#shadow)"/>
|
||||
<text x="105" y="112" class="hero-title">Cygnus Architecture Overview</text>
|
||||
<text x="105" y="157" class="hero-sub">Secure on-premises → cloud identity authentication while operational and applicant data remain on customer infrastructure</text>
|
||||
<rect x="105" y="180" width="212" height="30" class="chip" opacity=".22"/>
|
||||
<text x="123" y="201" class="small white bold">JDK 21 · Spring MVC</text>
|
||||
<rect x="330" y="180" width="245" height="30" class="chip" opacity=".22"/>
|
||||
<text x="348" y="201" class="small white bold">Spring WebFlux client</text>
|
||||
<rect x="588" y="180" width="280" height="30" class="chip" opacity=".22"/>
|
||||
<text x="606" y="201" class="small white bold">Spring Boot cloud service</text>
|
||||
<rect x="881" y="180" width="260" height="30" class="chip" opacity=".22"/>
|
||||
<text x="899" y="201" class="small white bold">PostgreSQL + Vert.x</text>
|
||||
<rect x="1154" y="180" width="212" height="30" class="chip" opacity=".22"/>
|
||||
<text x="1172" y="201" class="small white bold">Redis replay guard</text>
|
||||
|
||||
<!-- Architecture overview -->
|
||||
<text x="70" y="292" class="section-title">1 · Architecture overview</text>
|
||||
<text x="70" y="322" class="section-sub">The browser-facing application remains on-premises. Authentication and menu ownership are delegated to the cloud through a reusable client.</text>
|
||||
|
||||
<rect x="70" y="352" width="1780" height="490" class="card"/>
|
||||
<rect x="95" y="377" width="1050" height="440" class="onprem"/>
|
||||
<text x="125" y="414" class="box-title green">CUSTOMER PREMISES</text>
|
||||
<text x="125" y="439" class="box-sub">Applicant PII, punching, operations, reporting/MIS, JSP UI and HTTP session</text>
|
||||
|
||||
<rect x="130" y="480" width="210" height="126" class="soft-card"/>
|
||||
<text x="235" y="520" class="box-title" text-anchor="middle">Browser</text>
|
||||
<text x="235" y="550" class="small" text-anchor="middle">Login form / JSP UI</text>
|
||||
<text x="235" y="576" class="tiny muted" text-anchor="middle">POST /ver/authenticatelogin</text>
|
||||
|
||||
<rect x="430" y="455" width="300" height="178" class="soft-card"/>
|
||||
<text x="580" y="495" class="box-title" text-anchor="middle">cygnus-onprem-app</text>
|
||||
<text x="580" y="524" class="small" text-anchor="middle">Spring MVC + embedded Tomcat</text>
|
||||
<text x="580" y="552" class="tiny muted" text-anchor="middle">SessionController</text>
|
||||
<text x="580" y="575" class="tiny muted" text-anchor="middle">CloudAuthenticationGateway</text>
|
||||
<text x="580" y="598" class="tiny muted" text-anchor="middle">CloudSessionMapper</text>
|
||||
|
||||
<rect x="820" y="455" width="290" height="178" class="security"/>
|
||||
<text x="965" y="495" class="box-title" text-anchor="middle">cygnus-cloud-client</text>
|
||||
<text x="965" y="524" class="small" text-anchor="middle">Reusable WebClient gateway</text>
|
||||
<text x="965" y="552" class="tiny muted" text-anchor="middle">OAuthMachineTokenProvider</text>
|
||||
<text x="965" y="575" class="tiny muted" text-anchor="middle">LoginEnvelopeEncryptor</text>
|
||||
<text x="965" y="598" class="tiny muted" text-anchor="middle">CloudIdentityClient</text>
|
||||
|
||||
<path d="M340 543 H420" class="line-green"/>
|
||||
<text x="380" y="528" class="tiny green" text-anchor="middle">credentials</text>
|
||||
<path d="M730 543 H810" class="line-green"/>
|
||||
<text x="770" y="528" class="tiny green" text-anchor="middle">delegate</text>
|
||||
|
||||
<rect x="130" y="676" width="980" height="102" class="soft-card"/>
|
||||
<text x="155" y="711" class="box-title">Existing on-premises application behavior retained</text>
|
||||
<text x="155" y="741" class="small">Cloud response → legacy SessionValues + menu HTML → existing authorization, controllers, JSP pages and operational database access.</text>
|
||||
|
||||
<rect x="1190" y="377" width="635" height="440" class="cloud"/>
|
||||
<text x="1220" y="414" class="box-title blue">CYGNUS CLOUD</text>
|
||||
<text x="1220" y="439" class="box-sub">Vendor-controlled machine authorization, identity, permissions and menu data</text>
|
||||
|
||||
<rect x="1225" y="470" width="260" height="140" class="security"/>
|
||||
<text x="1355" y="507" class="box-title" text-anchor="middle">OAuth endpoint</text>
|
||||
<text x="1355" y="536" class="small" text-anchor="middle">POST /oauth2/token</text>
|
||||
<text x="1355" y="564" class="tiny muted" text-anchor="middle">Validate nested machine assertion</text>
|
||||
<text x="1355" y="586" class="tiny muted" text-anchor="middle">Issue short-lived RS256 JWT</text>
|
||||
|
||||
<rect x="1525" y="470" width="265" height="140" class="cloud"/>
|
||||
<text x="1657" y="507" class="box-title" text-anchor="middle">Identity API</text>
|
||||
<text x="1657" y="536" class="small" text-anchor="middle">POST /api/v1/identity/login</text>
|
||||
<text x="1657" y="564" class="tiny muted" text-anchor="middle">Decrypt login envelope</text>
|
||||
<text x="1657" y="586" class="tiny muted" text-anchor="middle">Authenticate and load menu</text>
|
||||
|
||||
<rect x="1225" y="660" width="260" height="110" class="database"/>
|
||||
<text x="1355" y="699" class="box-title" text-anchor="middle">Redis</text>
|
||||
<text x="1355" y="728" class="small" text-anchor="middle">Nonce replay protection</text>
|
||||
<text x="1355" y="751" class="tiny muted" text-anchor="middle">Atomic claim + short TTL</text>
|
||||
|
||||
<rect x="1525" y="660" width="265" height="110" class="database"/>
|
||||
<text x="1657" y="699" class="box-title" text-anchor="middle">PostgreSQL</text>
|
||||
<text x="1657" y="728" class="small" text-anchor="middle">matrix.identity schema</text>
|
||||
<text x="1657" y="751" class="tiny muted" text-anchor="middle">Vert.x prepared queries</text>
|
||||
|
||||
<path d="M1110 520 C1160 520 1160 520 1215 520" class="line-blue"/>
|
||||
<text x="1162" y="504" class="tiny blue" text-anchor="middle">machine assertion</text>
|
||||
<path d="M1110 585 C1320 625 1420 570 1515 540" class="line-blue"/>
|
||||
<text x="1320" y="605" class="tiny blue" text-anchor="middle">Bearer JWT + encrypted login</text>
|
||||
<path d="M1657 610 V650" class="line-blue"/>
|
||||
<path d="M1600 610 C1515 640 1450 670 1490 710" class="line-blue"/>
|
||||
|
||||
<!-- Responsibility split -->
|
||||
<text x="70" y="912" class="section-title">2 · Responsibility and data boundary</text>
|
||||
<text x="70" y="942" class="section-sub">The split is deliberate: identity and non-PI control data can be cloud-managed without transferring loan-applicant PII.</text>
|
||||
<rect x="70" y="972" width="855" height="265" class="card"/>
|
||||
<text x="105" y="1014" class="box-title green">Remains on customer premises</text>
|
||||
<text x="115" y="1054" class="body">• Punching and applicant intake</text>
|
||||
<text x="115" y="1086" class="body">• Operational allocation, scanning, telecalling and reports</text>
|
||||
<text x="115" y="1118" class="body">• Applicant PII, addresses, documents, photos and verification content</text>
|
||||
<text x="115" y="1150" class="body">• MIS generation and existing JSP/HTTP-session application behavior</text>
|
||||
<text x="115" y="1182" class="body">• Existing operational SQL, functions and views unrelated to cloud identity</text>
|
||||
|
||||
<rect x="945" y="972" width="905" height="265" class="card"/>
|
||||
<text x="980" y="1014" class="box-title blue">Managed by Cygnus cloud</text>
|
||||
<text x="990" y="1054" class="body">• Customer installation identity and machine authorization</text>
|
||||
<text x="990" y="1086" class="body">• Application users, groups, companies and branches required at login</text>
|
||||
<text x="990" y="1118" class="body">• Page permissions, denied pages and menu structure</text>
|
||||
<text x="990" y="1150" class="body">• Authentication audit entry and cloud access-token issuance</text>
|
||||
<text x="990" y="1182" class="body">• Redis nonce state used to reject replayed login envelopes</text>
|
||||
|
||||
<!-- Full runtime sequence -->
|
||||
<text x="70" y="1307" class="section-title">3 · Full authentication sequence</text>
|
||||
<text x="70" y="1337" class="section-sub">Every arrow below is part of one browser login. The access token is cached and reused until shortly before expiry.</text>
|
||||
|
||||
<rect x="70" y="1367" width="1780" height="1040" class="card"/>
|
||||
|
||||
<!-- Lane headers -->
|
||||
<rect x="90" y="1390" width="220" height="62" class="soft-card"/>
|
||||
<rect x="385" y="1390" width="245" height="62" class="onprem"/>
|
||||
<rect x="705" y="1390" width="245" height="62" class="security"/>
|
||||
<rect x="1025" y="1390" width="230" height="62" class="security"/>
|
||||
<rect x="1330" y="1390" width="240" height="62" class="cloud"/>
|
||||
<rect x="1640" y="1390" width="185" height="62" class="database"/>
|
||||
<text x="200" y="1428" class="box-title" text-anchor="middle">Browser</text>
|
||||
<text x="507" y="1428" class="box-title" text-anchor="middle">On-prem MVC</text>
|
||||
<text x="827" y="1428" class="box-title" text-anchor="middle">Cloud client</text>
|
||||
<text x="1140" y="1428" class="box-title" text-anchor="middle">OAuth service</text>
|
||||
<text x="1450" y="1428" class="box-title" text-anchor="middle">Identity service</text>
|
||||
<text x="1732" y="1428" class="box-title" text-anchor="middle">Redis / DB</text>
|
||||
|
||||
<line x1="200" y1="1452" x2="200" y2="2365" class="lane"/>
|
||||
<line x1="507" y1="1452" x2="507" y2="2365" class="lane"/>
|
||||
<line x1="827" y1="1452" x2="827" y2="2365" class="lane"/>
|
||||
<line x1="1140" y1="1452" x2="1140" y2="2365" class="lane"/>
|
||||
<line x1="1450" y1="1452" x2="1450" y2="2365" class="lane"/>
|
||||
<line x1="1732" y1="1452" x2="1732" y2="2365" class="lane"/>
|
||||
|
||||
<!-- Sequence messages -->
|
||||
<circle cx="95" cy="1500" r="15" class="step-circle"/><text x="95" y="1500" class="step-number">1</text>
|
||||
<path d="M200 1500 H497" class="line-green"/>
|
||||
<text x="348" y="1483" class="small bold" text-anchor="middle">POST login ID + password</text>
|
||||
<text x="348" y="1523" class="tiny muted" text-anchor="middle">/ver/authenticatelogin</text>
|
||||
|
||||
<circle cx="95" cy="1575" r="15" class="step-circle"/><text x="95" y="1575" class="step-number">2</text>
|
||||
<path d="M507 1575 H817" class="line-green"/>
|
||||
<text x="662" y="1558" class="small bold" text-anchor="middle">CloudAuthenticationGateway.authenticate()</text>
|
||||
<text x="662" y="1598" class="tiny muted" text-anchor="middle">Synchronous MVC → reactive client boundary</text>
|
||||
|
||||
<circle cx="95" cy="1650" r="15" class="step-circle"/><text x="95" y="1650" class="step-number">3</text>
|
||||
<path d="M827 1650 H1130" class="line-orange"/>
|
||||
<text x="978" y="1633" class="small bold" text-anchor="middle">POST machine assertion</text>
|
||||
<text x="978" y="1673" class="tiny muted" text-anchor="middle">Only when cached access token is absent/expiring</text>
|
||||
|
||||
<circle cx="95" cy="1725" r="15" class="step-circle"/><text x="95" y="1725" class="step-number">4</text>
|
||||
<path d="M1140 1725 H1440" class="line-orange"/>
|
||||
<text x="1290" y="1708" class="small bold" text-anchor="middle">Decrypt JWE + verify signed inner JWT</text>
|
||||
<text x="1290" y="1748" class="tiny muted" text-anchor="middle">Validate client ID, installation, audience and expiry</text>
|
||||
|
||||
<circle cx="95" cy="1800" r="15" class="step-circle"/><text x="95" y="1800" class="step-number">5</text>
|
||||
<path d="M1140 1800 H837" class="line-orange"/>
|
||||
<text x="988" y="1783" class="small bold" text-anchor="middle">Return short-lived RS256 access JWT</text>
|
||||
<text x="988" y="1823" class="tiny muted" text-anchor="middle">Claims: client_id, installation_id, scope, issuer, audience, expiry</text>
|
||||
|
||||
<circle cx="95" cy="1885" r="15" class="step-circle"/><text x="95" y="1885" class="step-number">6</text>
|
||||
<path d="M827 1885 H1440" class="line-blue"/>
|
||||
<text x="1133" y="1868" class="small bold" text-anchor="middle">Bearer JWT + encrypted login envelope</text>
|
||||
<text x="1133" y="1908" class="tiny muted" text-anchor="middle">AES-256-GCM payload; AES key wrapped by RSA-OAEP-256</text>
|
||||
|
||||
<circle cx="95" cy="1970" r="15" class="step-circle"/><text x="95" y="1970" class="step-number">7</text>
|
||||
<path d="M1450 1970 H1722" class="line-blue"/>
|
||||
<text x="1586" y="1953" class="small bold" text-anchor="middle">Atomically claim nonce in Redis</text>
|
||||
<text x="1586" y="1993" class="tiny muted" text-anchor="middle">Reject duplicate; apply short TTL</text>
|
||||
|
||||
<circle cx="95" cy="2045" r="15" class="step-circle"/><text x="95" y="2045" class="step-number">8</text>
|
||||
<path d="M1450 2045 H1722" class="line-blue"/>
|
||||
<text x="1586" y="2028" class="small bold" text-anchor="middle">Vert.x prepared SQL queries</text>
|
||||
<text x="1586" y="2068" class="tiny muted" text-anchor="middle">User → password check → menu → login audit</text>
|
||||
|
||||
<circle cx="95" cy="2120" r="15" class="step-circle"/><text x="95" y="2120" class="step-number">9</text>
|
||||
<path d="M1450 2120 H837" class="line-blue"/>
|
||||
<text x="1143" y="2103" class="small bold" text-anchor="middle">Authenticated identity + structured menu</text>
|
||||
<text x="1143" y="2143" class="tiny muted" text-anchor="middle">User, group, company, branch, permissions and login time</text>
|
||||
|
||||
<circle cx="95" cy="2195" r="15" class="step-circle"/><text x="95" y="2195" class="step-number">10</text>
|
||||
<path d="M827 2195 H517" class="line-green"/>
|
||||
<text x="672" y="2178" class="small bold" text-anchor="middle">Map cloud response to legacy SessionValues</text>
|
||||
<text x="672" y="2218" class="tiny muted" text-anchor="middle">Render menu HTML and preserve existing JSP expectations</text>
|
||||
|
||||
<circle cx="95" cy="2270" r="15" class="step-circle"/><text x="95" y="2270" class="step-number">11</text>
|
||||
<path d="M507 2270 H210" class="line-green"/>
|
||||
<text x="358" y="2253" class="small bold" text-anchor="middle">Store HTTP session and return home</text>
|
||||
<text x="358" y="2293" class="tiny muted" text-anchor="middle">Existing authorization and UI flow continues</text>
|
||||
|
||||
<rect x="118" y="2330" width="1684" height="52" class="security"/>
|
||||
<text x="960" y="2362" class="small bold" text-anchor="middle">Cloud identity is mandatory: there is no local password fallback. A cloud outage returns a controlled temporary-unavailable login message.</text>
|
||||
|
||||
<!-- Security model -->
|
||||
<text x="70" y="2477" class="section-title">4 · Security model</text>
|
||||
<text x="70" y="2507" class="section-sub">Machine identity, transport security, application-layer encryption and replay protection are separate layers.</text>
|
||||
<rect x="70" y="2537" width="1780" height="410" class="card"/>
|
||||
|
||||
<rect x="95" y="2570" width="405" height="330" class="security"/>
|
||||
<text x="125" y="2610" class="box-title orange">A · Machine assertion</text>
|
||||
<text x="125" y="2645" class="small">1. Inner JWT signed by installation private key</text>
|
||||
<text x="125" y="2674" class="small">2. Identifies client + installation</text>
|
||||
<text x="125" y="2703" class="small">3. Nested inside RSA-OAEP-256 JWE</text>
|
||||
<text x="125" y="2732" class="small">4. Cloud decrypts with assertion private key</text>
|
||||
<text x="125" y="2761" class="small">5. Signature checked using registered client key</text>
|
||||
<text x="125" y="2790" class="small">6. Assertion may be long-lived and is used only</text>
|
||||
<text x="143" y="2816" class="small">to obtain short-lived access tokens</text>
|
||||
<rect x="125" y="2845" width="335" height="34" class="chip"/>
|
||||
<text x="292" y="2868" class="tiny bold" text-anchor="middle">Customer-specific asymmetric identity</text>
|
||||
|
||||
<rect x="530" y="2570" width="405" height="330" class="security"/>
|
||||
<text x="560" y="2610" class="box-title orange">B · Access token</text>
|
||||
<text x="560" y="2645" class="small">• RS256 signed by the cloud</text>
|
||||
<text x="560" y="2674" class="small">• Short expiry; cached until refresh skew</text>
|
||||
<text x="560" y="2703" class="small">• Bound to client ID and installation ID</text>
|
||||
<text x="560" y="2732" class="small">• Carries identity.login scope</text>
|
||||
<text x="560" y="2761" class="small">• Issuer and audience validated</text>
|
||||
<text x="560" y="2790" class="small">• Required by Spring Security before the</text>
|
||||
<text x="578" y="2816" class="small">cloud identity controller executes</text>
|
||||
<rect x="560" y="2845" width="335" height="34" class="chip"/>
|
||||
<text x="727" y="2868" class="tiny bold" text-anchor="middle">Machine authorization, not user session</text>
|
||||
|
||||
<rect x="965" y="2570" width="405" height="330" class="security"/>
|
||||
<text x="995" y="2610" class="box-title orange">C · Login envelope</text>
|
||||
<text x="995" y="2645" class="small">• Fresh random AES-256 key per request</text>
|
||||
<text x="995" y="2674" class="small">• Credentials encrypted with AES-GCM</text>
|
||||
<text x="995" y="2703" class="small">• AES key encrypted by cloud RSA key</text>
|
||||
<text x="995" y="2732" class="small">• GCM detects ciphertext tampering</text>
|
||||
<text x="995" y="2761" class="small">• Payload contains nonce and timestamp</text>
|
||||
<text x="995" y="2790" class="small">• Client/installation values must match</text>
|
||||
<text x="1013" y="2816" class="small">the authenticated access-token claims</text>
|
||||
<rect x="995" y="2845" width="335" height="34" class="chip"/>
|
||||
<text x="1162" y="2868" class="tiny bold" text-anchor="middle">Application encryption in addition to TLS</text>
|
||||
|
||||
<rect x="1400" y="2570" width="405" height="330" class="security"/>
|
||||
<text x="1430" y="2610" class="box-title orange">D · Replay and transport</text>
|
||||
<text x="1430" y="2645" class="small">• Redis SET-if-absent claims each nonce</text>
|
||||
<text x="1430" y="2674" class="small">• Duplicate nonce is rejected</text>
|
||||
<text x="1430" y="2703" class="small">• Timestamp freshness window enforced</text>
|
||||
<text x="1430" y="2732" class="small">• HTTPS is mandatory outside local dev</text>
|
||||
<text x="1430" y="2761" class="small">• Credentials and tokens are not logged</text>
|
||||
<text x="1430" y="2790" class="small">• Private keys and assertions require</text>
|
||||
<text x="1448" y="2816" class="small">owner-only storage and rotation controls</text>
|
||||
<rect x="1430" y="2845" width="335" height="34" class="chip"/>
|
||||
<text x="1597" y="2868" class="tiny bold" text-anchor="middle">Captured requests cannot be reused</text>
|
||||
|
||||
<!-- Cloud login internals -->
|
||||
<text x="70" y="3017" class="section-title">5 · Cloud login internals and session compatibility</text>
|
||||
<text x="70" y="3047" class="section-sub">Prepared SQL retrieves only the identity data needed to preserve the existing SessionValues contract.</text>
|
||||
<rect x="70" y="3077" width="1095" height="500" class="card"/>
|
||||
<text x="105" y="3120" class="box-title blue">Cloud identity processing</text>
|
||||
|
||||
<circle cx="125" cy="3170" r="14" class="step-circle"/><text x="125" y="3170" class="step-number">1</text>
|
||||
<text x="155" y="3176" class="body">Decrypt payload and validate machine binding, timestamp and nonce.</text>
|
||||
<circle cx="125" cy="3225" r="14" class="step-circle"/><text x="125" y="3225" class="step-number">2</text>
|
||||
<text x="155" y="3231" class="body">Load active user by case-insensitive login ID from identity.app_user.</text>
|
||||
<circle cx="125" cy="3280" r="14" class="step-circle"/><text x="125" y="3280" class="step-number">3</text>
|
||||
<text x="155" y="3286" class="body">Verify password using the compatible legacy password verifier.</text>
|
||||
<circle cx="125" cy="3335" r="14" class="step-circle"/><text x="125" y="3335" class="step-number">4</text>
|
||||
<text x="155" y="3341" class="body">Load group, company and branch details.</text>
|
||||
<circle cx="125" cy="3390" r="14" class="step-circle"/><text x="125" y="3390" class="step-number">5</text>
|
||||
<text x="155" y="3396" class="body">Load visible permitted pages and exclude explicitly denied pages.</text>
|
||||
<circle cx="125" cy="3445" r="14" class="step-circle"/><text x="125" y="3445" class="step-number">6</text>
|
||||
<text x="155" y="3451" class="body">Insert cloud login audit row and return structured identity/menu response.</text>
|
||||
<rect x="105" y="3490" width="1025" height="58" class="database"/>
|
||||
<text x="617" y="3514" class="small bold" text-anchor="middle">Vert.x prepared queries keep values separate from SQL text</text>
|
||||
<text x="617" y="3538" class="tiny muted" text-anchor="middle">Tables: identity.app_user · user_group · company · company_branch · permission · pages · denied_pages · user_loginhistory</text>
|
||||
|
||||
<rect x="1195" y="3077" width="655" height="500" class="card"/>
|
||||
<text x="1230" y="3120" class="box-title green">Legacy compatibility mapping</text>
|
||||
<text x="1230" y="3158" class="small bold">CloudSessionMapper populates:</text>
|
||||
<text x="1250" y="3192" class="small">• User ID, login ID and display name</text>
|
||||
<text x="1250" y="3222" class="small">• Group ID and group name</text>
|
||||
<text x="1250" y="3252" class="small">• Company ID, name and code</text>
|
||||
<text x="1250" y="3282" class="small">• Branch ID, name, code and location</text>
|
||||
<text x="1250" y="3312" class="small">• Login timestamp in the legacy format</text>
|
||||
<text x="1250" y="3342" class="small">• Menu HTML rendered from cloud menu records</text>
|
||||
<rect x="1230" y="3380" width="585" height="116" class="onprem"/>
|
||||
<text x="1522" y="3414" class="small bold" text-anchor="middle">No local password query or fallback remains</text>
|
||||
<text x="1522" y="3443" class="small" text-anchor="middle">Existing JSP controllers keep reading SessionValues</text>
|
||||
<text x="1522" y="3472" class="small" text-anchor="middle">Operational authorization continues without UI rewrites</text>
|
||||
<text x="1230" y="3535" class="tiny muted">Logout currently invalidates the local HTTP session. A cloud logout/audit API is the future option if logout timestamps are required.</text>
|
||||
|
||||
<!-- Code navigation and failures -->
|
||||
<text x="70" y="3647" class="section-title">6 · Code navigation</text>
|
||||
<text x="70" y="3677" class="section-sub">Primary implementation points: SessionController → CloudAuthenticationGateway → OAuthMachineTokenProvider / LoginEnvelopeEncryptor → CloudLoginController → IdentityAuthenticationService → IdentityRepository → CloudSessionMapper.</text>
|
||||
|
||||
<!-- Footer strip overlays final area intentionally within document bounds -->
|
||||
<rect x="50" y="3695" width="1820" height="1" fill="#9eb5c5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 26 KiB |
BIN
Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx
Normal file
BIN
Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx
Normal file
Binary file not shown.
186
README.md
186
README.md
@@ -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.
|
||||
|
||||
70
cygnus-cloud-client/README.md
Normal file
70
cygnus-cloud-client/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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, `config/clients.yml`, and the encrypted
|
||||
machine assertion:
|
||||
|
||||
```bash
|
||||
./scripts/setup-local-communication.sh
|
||||
```
|
||||
|
||||
The script interactively asks for the customer identifier, installation
|
||||
identifier, cloud URL, and whether to run the full verification. Customer and
|
||||
installation identifiers cannot contain spaces; the customer identifier is
|
||||
used for its directory and signing-key filenames. New customers are appended
|
||||
to `config/clients.yml` without replacing existing customers.
|
||||
|
||||
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"
|
||||
```
|
||||
|
||||
Copy only `client-signing-public.pem` into that customer's cloud-side client
|
||||
configuration. Keep the private key and generated assertion on the on-premises
|
||||
server with owner-only filesystem permissions.
|
||||
57
cygnus-cloud-client/pom.xml
Normal file
57
cygnus-cloud-client/pom.xml
Normal file
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.cygnus</groupId>
|
||||
<artifactId>cygnus-parent</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>cygnus-cloud-client</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Cygnus Cloud Client</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-webflux</artifactId>
|
||||
<version>${spring.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor.netty</groupId>
|
||||
<artifactId>reactor-netty-http</artifactId>
|
||||
<version>${reactor.netty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
<version>${nimbus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cygnus.client;
|
||||
|
||||
import com.cygnus.client.security.LoginEnvelopeEncryptor;
|
||||
import com.cygnus.client.security.MachineTokenProvider;
|
||||
import com.cygnus.client.security.OAuthMachineTokenProvider;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.Clock;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
public final class CloudClientFactory {
|
||||
|
||||
private CloudClientFactory() {
|
||||
}
|
||||
|
||||
public static CloudIdentityClient create(
|
||||
CloudClientProperties properties, ObjectMapper objectMapper, Clock clock) {
|
||||
WebClient webClient = WebClient.builder().build();
|
||||
MachineTokenProvider tokenProvider =
|
||||
new OAuthMachineTokenProvider(webClient, properties, clock);
|
||||
LoginEnvelopeEncryptor encryptor =
|
||||
new LoginEnvelopeEncryptor(properties, objectMapper);
|
||||
return new CloudIdentityClient(
|
||||
webClient, tokenProvider, encryptor, properties, clock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.cygnus.client;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public record CloudClientProperties(
|
||||
URI baseUri,
|
||||
URI tokenUri,
|
||||
String clientId,
|
||||
String installationId,
|
||||
String clientAssertion,
|
||||
String loginKeyId,
|
||||
String loginPublicKeyLocation,
|
||||
Duration requestTimeout,
|
||||
Duration tokenRefreshSkew) {
|
||||
|
||||
public static CloudClientProperties fromSystem() {
|
||||
CloudClientProperties properties = new CloudClientProperties(
|
||||
URI.create(value("CYGNUS_CLOUD_BASE_URL", "http://localhost:8090")),
|
||||
URI.create(value(
|
||||
"CYGNUS_TOKEN_URL",
|
||||
"http://localhost:8090/oauth2/token")),
|
||||
value("CYGNUS_CLIENT_ID", ""),
|
||||
value("CYGNUS_INSTALLATION_ID", ""),
|
||||
value("CYGNUS_CLIENT_ASSERTION", ""),
|
||||
value("CYGNUS_LOGIN_KEY_ID", "cygnus-login-2026-01"),
|
||||
value(
|
||||
"CYGNUS_LOGIN_PUBLIC_KEY",
|
||||
"file:./config/keys/login-public.pem"),
|
||||
Duration.parse(value("CYGNUS_CLOUD_REQUEST_TIMEOUT", "PT10S")),
|
||||
Duration.parse(value("CYGNUS_TOKEN_REFRESH_SKEW", "PT30S")));
|
||||
properties.validate();
|
||||
return properties;
|
||||
}
|
||||
|
||||
public void validate() {
|
||||
List<String> missing = new ArrayList<>();
|
||||
require(clientId, "CYGNUS_CLIENT_ID", missing);
|
||||
require(installationId, "CYGNUS_INSTALLATION_ID", missing);
|
||||
require(clientAssertion, "CYGNUS_CLIENT_ASSERTION", missing);
|
||||
require(loginKeyId, "CYGNUS_LOGIN_KEY_ID", missing);
|
||||
require(loginPublicKeyLocation, "CYGNUS_LOGIN_PUBLIC_KEY", missing);
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"Cloud identity requires these settings: "
|
||||
+ String.join(", ", missing));
|
||||
}
|
||||
if (requestTimeout.isZero() || requestTimeout.isNegative()) {
|
||||
throw new IllegalStateException(
|
||||
"CYGNUS_CLOUD_REQUEST_TIMEOUT must be greater than zero");
|
||||
}
|
||||
}
|
||||
|
||||
private static void require(String value, String name, List<String> missing) {
|
||||
if (value == null || value.isBlank()) {
|
||||
missing.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static String value(String name, String defaultValue) {
|
||||
String systemValue = System.getProperty(name);
|
||||
if (systemValue != null) {
|
||||
return systemValue;
|
||||
}
|
||||
String environmentValue = System.getenv(name);
|
||||
return environmentValue == null ? defaultValue : environmentValue;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record CloudIdentitySession(
|
||||
short userId,
|
||||
String loginId,
|
||||
String displayName,
|
||||
short groupId,
|
||||
String groupName,
|
||||
short branchId,
|
||||
String branchName,
|
||||
String branchCode,
|
||||
String branchLocation,
|
||||
short companyId,
|
||||
String companyName,
|
||||
String companyCode,
|
||||
Instant loginTime,
|
||||
List<CloudMenuItem> menu) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
public record CloudMenuItem(
|
||||
short pageId,
|
||||
String label,
|
||||
String targetUrl,
|
||||
short parentPage,
|
||||
short pageOrder,
|
||||
String permission,
|
||||
String targetWindow,
|
||||
String requestValue) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
public record EncryptedLoginRequest(
|
||||
String keyId,
|
||||
String encryptedKey,
|
||||
String initializationVector,
|
||||
String encryptedPayload) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginPayload(
|
||||
String loginId,
|
||||
String password,
|
||||
String clientId,
|
||||
String installationId,
|
||||
String nonce,
|
||||
Instant issuedAt) {
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.cygnus.client.provisioning;
|
||||
|
||||
import com.nimbusds.jose.EncryptionMethod;
|
||||
import com.nimbusds.jose.JWEAlgorithm;
|
||||
import com.nimbusds.jose.JWEHeader;
|
||||
import com.nimbusds.jose.JWEObject;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.Payload;
|
||||
import com.nimbusds.jose.crypto.RSAEncrypter;
|
||||
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
|
||||
public final class MachineAssertionGenerator {
|
||||
|
||||
private MachineAssertionGenerator() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length != 6) {
|
||||
throw new IllegalArgumentException(
|
||||
"Expected: <client-id> <installation-id> <token-audience> "
|
||||
+ "<client-signing-private.pem> "
|
||||
+ "<cloud-assertion-public.pem> <output-file>");
|
||||
}
|
||||
Instant issuedAt = Instant.now();
|
||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||
.issuer(args[0])
|
||||
.subject(args[0])
|
||||
.audience(args[2])
|
||||
.issueTime(Date.from(issuedAt))
|
||||
.expirationTime(Date.from(issuedAt.plus(365, ChronoUnit.DAYS)))
|
||||
.claim("installation_id", args[1])
|
||||
.build();
|
||||
SignedJWT signed = new SignedJWT(
|
||||
new JWSHeader(JWSAlgorithm.RS256), claims);
|
||||
signed.sign(new RSASSASigner(privateKey(Path.of(args[3]))));
|
||||
|
||||
JWEObject encrypted = new JWEObject(
|
||||
new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM),
|
||||
new Payload(signed.serialize()));
|
||||
encrypted.encrypt(new RSAEncrypter(publicKey(Path.of(args[4]))));
|
||||
|
||||
Path output = Path.of(args[5]).toAbsolutePath();
|
||||
Files.writeString(
|
||||
output,
|
||||
encrypted.serialize(),
|
||||
StandardCharsets.US_ASCII,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
output,
|
||||
Set.of(
|
||||
java.nio.file.attribute.PosixFilePermission.OWNER_READ,
|
||||
java.nio.file.attribute.PosixFilePermission.OWNER_WRITE));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// Non-POSIX platforms must secure this file through their native ACL.
|
||||
}
|
||||
System.out.println("Machine assertion written to " + output);
|
||||
}
|
||||
|
||||
private static RSAPrivateKey privateKey(Path path) throws Exception {
|
||||
String encoded = pem(path, "PRIVATE KEY");
|
||||
return (RSAPrivateKey) KeyFactory.getInstance("RSA")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
}
|
||||
|
||||
private static RSAPublicKey publicKey(Path path) throws Exception {
|
||||
String encoded = pem(path, "PUBLIC KEY");
|
||||
return (RSAPublicKey) KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
}
|
||||
|
||||
private static String pem(Path path, String type) throws Exception {
|
||||
return Files.readString(path, StandardCharsets.US_ASCII)
|
||||
.replace("-----BEGIN " + type + "-----", "")
|
||||
.replace("-----END " + type + "-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.client.security;
|
||||
|
||||
public class CloudClientException extends RuntimeException {
|
||||
|
||||
public CloudClientException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public CloudClientException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.cygnus.client.security;
|
||||
|
||||
import com.cygnus.client.CloudClientProperties;
|
||||
import com.cygnus.client.model.EncryptedLoginRequest;
|
||||
import com.cygnus.client.model.LoginPayload;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.OAEPParameterSpec;
|
||||
import javax.crypto.spec.PSource;
|
||||
|
||||
public class LoginEnvelopeEncryptor {
|
||||
|
||||
private static final OAEPParameterSpec OAEP_SHA_256 = new OAEPParameterSpec(
|
||||
"SHA-256",
|
||||
"MGF1",
|
||||
MGF1ParameterSpec.SHA256,
|
||||
PSource.PSpecified.DEFAULT);
|
||||
|
||||
private final CloudClientProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SecureRandom secureRandom;
|
||||
private volatile PublicKey publicKey;
|
||||
|
||||
public LoginEnvelopeEncryptor(
|
||||
CloudClientProperties properties, ObjectMapper objectMapper) {
|
||||
this(properties, objectMapper, new SecureRandom());
|
||||
}
|
||||
|
||||
LoginEnvelopeEncryptor(
|
||||
CloudClientProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
SecureRandom secureRandom) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.secureRandom = secureRandom;
|
||||
}
|
||||
|
||||
public EncryptedLoginRequest encrypt(LoginPayload payload) {
|
||||
try {
|
||||
KeyGenerator generator = KeyGenerator.getInstance("AES");
|
||||
generator.init(256, secureRandom);
|
||||
SecretKey aesKey = generator.generateKey();
|
||||
byte[] initializationVector = new byte[12];
|
||||
secureRandom.nextBytes(initializationVector);
|
||||
|
||||
Cipher payloadCipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
payloadCipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
aesKey,
|
||||
new GCMParameterSpec(128, initializationVector));
|
||||
payloadCipher.updateAAD(
|
||||
properties.loginKeyId().getBytes(StandardCharsets.UTF_8));
|
||||
byte[] encryptedPayload =
|
||||
payloadCipher.doFinal(objectMapper.writeValueAsBytes(payload));
|
||||
|
||||
Cipher keyCipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
|
||||
keyCipher.init(Cipher.ENCRYPT_MODE, publicKey(), OAEP_SHA_256);
|
||||
byte[] encryptedKey = keyCipher.doFinal(aesKey.getEncoded());
|
||||
|
||||
Base64.Encoder base64 = Base64.getEncoder();
|
||||
return new EncryptedLoginRequest(
|
||||
properties.loginKeyId(),
|
||||
base64.encodeToString(encryptedKey),
|
||||
base64.encodeToString(initializationVector),
|
||||
base64.encodeToString(encryptedPayload));
|
||||
} catch (Exception exception) {
|
||||
throw new CloudClientException("Unable to encrypt cloud login request", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PublicKey publicKey() throws Exception {
|
||||
PublicKey loaded = publicKey;
|
||||
if (loaded != null) {
|
||||
return loaded;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (publicKey == null) {
|
||||
String pem;
|
||||
String location = properties.loginPublicKeyLocation();
|
||||
if (location.startsWith("classpath:")) {
|
||||
String resourceName = location.substring("classpath:".length());
|
||||
try (InputStream stream = Thread.currentThread()
|
||||
.getContextClassLoader()
|
||||
.getResourceAsStream(resourceName)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Public key resource not found: " + resourceName);
|
||||
}
|
||||
pem = new String(stream.readAllBytes(), StandardCharsets.US_ASCII);
|
||||
}
|
||||
} else {
|
||||
String fileName =
|
||||
location.startsWith("file:") ? location.substring(5) : location;
|
||||
pem = Files.readString(Path.of(fileName), StandardCharsets.US_ASCII);
|
||||
}
|
||||
String encoded = pem
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
publicKey = KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
}
|
||||
return publicKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.client.security;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface MachineTokenProvider {
|
||||
Mono<String> accessToken();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.cygnus.client.security;
|
||||
|
||||
import com.cygnus.client.CloudClientProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class OAuthMachineTokenProvider implements MachineTokenProvider {
|
||||
|
||||
private static final String ASSERTION_TYPE =
|
||||
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
||||
|
||||
private final WebClient webClient;
|
||||
private final CloudClientProperties properties;
|
||||
private final Clock clock;
|
||||
private final AtomicReference<CachedToken> cachedToken = new AtomicReference<>();
|
||||
private volatile Mono<String> tokenRequest;
|
||||
|
||||
public OAuthMachineTokenProvider(
|
||||
WebClient webClient, CloudClientProperties properties, Clock clock) {
|
||||
this.webClient = webClient;
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<String> accessToken() {
|
||||
CachedToken current = cachedToken.get();
|
||||
if (current != null
|
||||
&& clock.instant().isBefore(
|
||||
current.expiresAt().minus(properties.tokenRefreshSkew()))) {
|
||||
return Mono.just(current.value());
|
||||
}
|
||||
return refreshToken();
|
||||
}
|
||||
|
||||
private synchronized Mono<String> refreshToken() {
|
||||
CachedToken current = cachedToken.get();
|
||||
if (current != null
|
||||
&& clock.instant().isBefore(
|
||||
current.expiresAt().minus(properties.tokenRefreshSkew()))) {
|
||||
return Mono.just(current.value());
|
||||
}
|
||||
if (tokenRequest == null) {
|
||||
tokenRequest = webClient.post()
|
||||
.uri(properties.tokenUri())
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(BodyInserters.fromFormData("grant_type", "client_credentials")
|
||||
.with("client_id", properties.clientId())
|
||||
.with("client_assertion_type", ASSERTION_TYPE)
|
||||
.with("client_assertion", clientAssertion())
|
||||
.with("scope", "identity.login"))
|
||||
.retrieve()
|
||||
.bodyToMono(TokenResponse.class)
|
||||
.map(response -> {
|
||||
cachedToken.set(new CachedToken(
|
||||
response.accessToken(),
|
||||
clock.instant().plusSeconds(response.expiresIn())));
|
||||
return response.accessToken();
|
||||
})
|
||||
.doFinally(signal -> clearInFlight())
|
||||
.cache();
|
||||
}
|
||||
return tokenRequest;
|
||||
}
|
||||
|
||||
private synchronized void clearInFlight() {
|
||||
tokenRequest = null;
|
||||
}
|
||||
|
||||
private String clientAssertion() {
|
||||
String configured = properties.clientAssertion();
|
||||
if (!configured.startsWith("file:")) {
|
||||
return configured;
|
||||
}
|
||||
try {
|
||||
return Files.readString(
|
||||
Path.of(configured.substring("file:".length())),
|
||||
StandardCharsets.US_ASCII)
|
||||
.trim();
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Unable to read machine client assertion", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record CachedToken(String value, Instant expiresAt) {
|
||||
}
|
||||
|
||||
private record TokenResponse(
|
||||
@JsonProperty("access_token") String accessToken,
|
||||
@JsonProperty("expires_in") long expiresIn,
|
||||
@JsonProperty("token_type") String tokenType) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.cygnus.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CloudClientPropertiesTest {
|
||||
|
||||
@Test
|
||||
void configurationRequiresMachineCredentials() {
|
||||
CloudClientProperties properties = properties("", "", "");
|
||||
|
||||
assertThrows(IllegalStateException.class, properties::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void configurationAcceptsCompleteMachineIdentity() {
|
||||
CloudClientProperties properties =
|
||||
properties("customer-a", "site-01", "signed-assertion");
|
||||
|
||||
assertDoesNotThrow(properties::validate);
|
||||
}
|
||||
|
||||
private CloudClientProperties properties(
|
||||
String clientId,
|
||||
String installationId,
|
||||
String assertion) {
|
||||
return new CloudClientProperties(
|
||||
URI.create("https://cloud.example.test"),
|
||||
URI.create("https://identity.example.test/oauth2/token"),
|
||||
clientId,
|
||||
installationId,
|
||||
assertion,
|
||||
"login-key-01",
|
||||
"file:./login-public.pem",
|
||||
Duration.ofSeconds(10),
|
||||
Duration.ofSeconds(30));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3
cygnus-cloud-client/target/maven-archiver/pom.properties
Normal file
3
cygnus-cloud-client/target/maven-archiver/pom.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
artifactId=cygnus-cloud-client
|
||||
groupId=com.cygnus
|
||||
version=1.0.0-SNAPSHOT
|
||||
@@ -0,0 +1,14 @@
|
||||
com/cygnus/client/security/MachineTokenProvider.class
|
||||
com/cygnus/client/security/OAuthMachineTokenProvider$TokenResponse.class
|
||||
com/cygnus/client/provisioning/MachineAssertionGenerator.class
|
||||
com/cygnus/client/CloudClientProperties.class
|
||||
com/cygnus/client/security/LoginEnvelopeEncryptor.class
|
||||
com/cygnus/client/model/LoginPayload.class
|
||||
com/cygnus/client/model/CloudIdentitySession.class
|
||||
com/cygnus/client/security/OAuthMachineTokenProvider.class
|
||||
com/cygnus/client/security/OAuthMachineTokenProvider$CachedToken.class
|
||||
com/cygnus/client/CloudClientFactory.class
|
||||
com/cygnus/client/model/CloudMenuItem.class
|
||||
com/cygnus/client/model/EncryptedLoginRequest.class
|
||||
com/cygnus/client/security/CloudClientException.class
|
||||
com/cygnus/client/CloudIdentityClient.class
|
||||
@@ -0,0 +1,12 @@
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientFactory.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientProperties.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudIdentityClient.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudIdentitySession.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudMenuItem.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/EncryptedLoginRequest.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/LoginPayload.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/provisioning/MachineAssertionGenerator.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/CloudClientException.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/LoginEnvelopeEncryptor.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/MachineTokenProvider.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/OAuthMachineTokenProvider.java
|
||||
@@ -0,0 +1 @@
|
||||
com/cygnus/client/CloudClientPropertiesTest.class
|
||||
@@ -0,0 +1 @@
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/test/java/com/cygnus/client/CloudClientPropertiesTest.java
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report.xsd" version="3.0.2" name="com.cygnus.client.CloudClientPropertiesTest" time="0.016" tests="2" errors="0" skipped="0" failures="0">
|
||||
<properties>
|
||||
<property name="java.specification.version" value="21"/>
|
||||
<property name="sun.jnu.encoding" value="UTF-8"/>
|
||||
<property name="java.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/test-classes:/Users/maddy/Projects/matrix/cygnus-cloud-client/target/classes:/Users/maddy/.m2/repository/org/springframework/spring-webflux/6.2.19/spring-webflux-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-beans/6.2.19/spring-beans-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-core/6.2.19/spring-core-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-jcl/6.2.19/spring-jcl-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-web/6.2.19/spring-web-6.2.19.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-observation/1.15.12/micrometer-observation-1.15.12.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-commons/1.15.12/micrometer-commons-1.15.12.jar:/Users/maddy/.m2/repository/io/projectreactor/reactor-core/3.7.19/reactor-core-3.7.19.jar:/Users/maddy/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-http/1.2.8/reactor-netty-http-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http/4.1.122.Final/netty-codec-http-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-common/4.1.122.Final/netty-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-buffer/4.1.122.Final/netty-buffer-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport/4.1.122.Final/netty-transport-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec/4.1.122.Final/netty-codec-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-handler/4.1.122.Final/netty-handler-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http2/4.1.122.Final/netty-codec-http2-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns/4.1.122.Final/netty-resolver-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver/4.1.122.Final/netty-resolver-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-dns/4.1.122.Final/netty-codec-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-native-macos/4.1.122.Final/netty-resolver-dns-native-macos-4.1.122.Final-osx-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-classes-macos/4.1.122.Final/netty-resolver-dns-classes-macos-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-epoll/4.1.122.Final/netty-transport-native-epoll-4.1.122.Final-linux-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-unix-common/4.1.122.Final/netty-transport-native-unix-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-classes-epoll/4.1.122.Final/netty-transport-classes-epoll-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-core/1.2.8/reactor-netty-core-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-handler-proxy/4.1.122.Final/netty-handler-proxy-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-socks/4.1.122.Final/netty-codec-socks-4.1.122.Final.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.18.6/jackson-databind-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.18.6/jackson-annotations-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.18.6/jackson-core-2.18.6.jar:/Users/maddy/.m2/repository/com/nimbusds/nimbus-jose-jwt/10.4/nimbus-jose-jwt-10.4.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter/5.12.2/junit-jupiter-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.12.2/junit-jupiter-api-5.12.2.jar:/Users/maddy/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-commons/1.12.2/junit-platform-commons-1.12.2.jar:/Users/maddy/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.12.2/junit-jupiter-params-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.12.2/junit-jupiter-engine-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-engine/1.12.2/junit-platform-engine-1.12.2.jar:"/>
|
||||
<property name="java.vm.vendor" value="Microsoft"/>
|
||||
<property name="sun.arch.data.model" value="64"/>
|
||||
<property name="java.vendor.url" value="https://www.microsoft.com"/>
|
||||
<property name="os.name" value="Mac OS X"/>
|
||||
<property name="java.vm.specification.version" value="21"/>
|
||||
<property name="sun.java.launcher" value="SUN_STANDARD"/>
|
||||
<property name="user.country" value="US"/>
|
||||
<property name="sun.boot.library.path" value="/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home/lib"/>
|
||||
<property name="sun.java.command" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260724215520397_3.jar /Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire 2026-07-24T21-55-20_356-jvmRun1 surefire-20260724215520397_1tmp surefire_0-20260724215520397_2tmp"/>
|
||||
<property name="http.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
||||
<property name="jdk.debug" value="release"/>
|
||||
<property name="test" value="CloudClientPropertiesTest"/>
|
||||
<property name="surefire.test.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/test-classes:/Users/maddy/Projects/matrix/cygnus-cloud-client/target/classes:/Users/maddy/.m2/repository/org/springframework/spring-webflux/6.2.19/spring-webflux-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-beans/6.2.19/spring-beans-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-core/6.2.19/spring-core-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-jcl/6.2.19/spring-jcl-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-web/6.2.19/spring-web-6.2.19.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-observation/1.15.12/micrometer-observation-1.15.12.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-commons/1.15.12/micrometer-commons-1.15.12.jar:/Users/maddy/.m2/repository/io/projectreactor/reactor-core/3.7.19/reactor-core-3.7.19.jar:/Users/maddy/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-http/1.2.8/reactor-netty-http-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http/4.1.122.Final/netty-codec-http-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-common/4.1.122.Final/netty-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-buffer/4.1.122.Final/netty-buffer-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport/4.1.122.Final/netty-transport-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec/4.1.122.Final/netty-codec-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-handler/4.1.122.Final/netty-handler-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http2/4.1.122.Final/netty-codec-http2-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns/4.1.122.Final/netty-resolver-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver/4.1.122.Final/netty-resolver-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-dns/4.1.122.Final/netty-codec-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-native-macos/4.1.122.Final/netty-resolver-dns-native-macos-4.1.122.Final-osx-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-classes-macos/4.1.122.Final/netty-resolver-dns-classes-macos-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-epoll/4.1.122.Final/netty-transport-native-epoll-4.1.122.Final-linux-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-unix-common/4.1.122.Final/netty-transport-native-unix-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-classes-epoll/4.1.122.Final/netty-transport-classes-epoll-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-core/1.2.8/reactor-netty-core-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-handler-proxy/4.1.122.Final/netty-handler-proxy-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-socks/4.1.122.Final/netty-codec-socks-4.1.122.Final.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.18.6/jackson-databind-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.18.6/jackson-annotations-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.18.6/jackson-core-2.18.6.jar:/Users/maddy/.m2/repository/com/nimbusds/nimbus-jose-jwt/10.4/nimbus-jose-jwt-10.4.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter/5.12.2/junit-jupiter-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.12.2/junit-jupiter-api-5.12.2.jar:/Users/maddy/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-commons/1.12.2/junit-platform-commons-1.12.2.jar:/Users/maddy/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.12.2/junit-jupiter-params-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.12.2/junit-jupiter-engine-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-engine/1.12.2/junit-platform-engine-1.12.2.jar:"/>
|
||||
<property name="sun.cpu.endian" value="little"/>
|
||||
<property name="user.home" value="/Users/maddy"/>
|
||||
<property name="user.language" value="en"/>
|
||||
<property name="java.specification.vendor" value="Oracle Corporation"/>
|
||||
<property name="java.version.date" value="2025-07-15"/>
|
||||
<property name="java.home" value="/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home"/>
|
||||
<property name="file.separator" value="/"/>
|
||||
<property name="basedir" value="/Users/maddy/Projects/matrix/cygnus-cloud-client"/>
|
||||
<property name="java.vm.compressedOopsMode" value="Zero based"/>
|
||||
<property name="line.separator" value=" "/>
|
||||
<property name="java.vm.specification.vendor" value="Oracle Corporation"/>
|
||||
<property name="java.specification.name" value="Java Platform API Specification"/>
|
||||
<property name="apple.awt.application.name" value="ForkedBooter"/>
|
||||
<property name="surefire.real.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260724215520397_3.jar"/>
|
||||
<property name="sun.management.compiler" value="HotSpot 64-Bit Tiered Compilers"/>
|
||||
<property name="ftp.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
||||
<property name="java.runtime.version" value="21.0.8+9-LTS"/>
|
||||
<property name="user.name" value="maddy"/>
|
||||
<property name="stdout.encoding" value="UTF-8"/>
|
||||
<property name="path.separator" value=":"/>
|
||||
<property name="os.version" value="26.5.2"/>
|
||||
<property name="java.runtime.name" value="OpenJDK Runtime Environment"/>
|
||||
<property name="file.encoding" value="UTF-8"/>
|
||||
<property name="java.vm.name" value="OpenJDK 64-Bit Server VM"/>
|
||||
<property name="java.vendor.version" value="Microsoft-11933201"/>
|
||||
<property name="localRepository" value="/Users/maddy/.m2/repository"/>
|
||||
<property name="java.vendor.url.bug" value="https://github.com/microsoft/openjdk/issues"/>
|
||||
<property name="java.io.tmpdir" value="/var/folders/1l/36214rdn79755j30lcnmgsqh0000gn/T/"/>
|
||||
<property name="java.version" value="21.0.8"/>
|
||||
<property name="user.dir" value="/Users/maddy/Projects/matrix/cygnus-cloud-client"/>
|
||||
<property name="os.arch" value="aarch64"/>
|
||||
<property name="java.vm.specification.name" value="Java Virtual Machine Specification"/>
|
||||
<property name="native.encoding" value="UTF-8"/>
|
||||
<property name="java.library.path" value="/Users/maddy/Library/Java/Extensions:/Library/Java/Extensions:/Network/Library/Java/Extensions:/System/Library/Java/Extensions:/usr/lib/java:."/>
|
||||
<property name="java.vm.info" value="mixed mode, sharing"/>
|
||||
<property name="stderr.encoding" value="UTF-8"/>
|
||||
<property name="java.vendor" value="Microsoft"/>
|
||||
<property name="java.vm.version" value="21.0.8+9-LTS"/>
|
||||
<property name="sun.io.unicode.encoding" value="UnicodeBig"/>
|
||||
<property name="socksNonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
||||
<property name="java.class.version" value="65.0"/>
|
||||
</properties>
|
||||
<testcase name="configurationAcceptsCompleteMachineIdentity" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.007"/>
|
||||
<testcase name="configurationRequiresMachineCredentials" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.001"/>
|
||||
</testsuite>
|
||||
@@ -0,0 +1,4 @@
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.cygnus.client.CloudClientPropertiesTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.016 s -- in com.cygnus.client.CloudClientPropertiesTest
|
||||
Binary file not shown.
98
cygnus-cloud-service/README.md
Normal file
98
cygnus-cloud-service/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 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 `client_id`,
|
||||
`installation_id`, and the approved scope. The identity endpoint requires the
|
||||
`identity.login` scope and verifies the same machine 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
|
||||
```
|
||||
|
||||
Configure clients in an external Spring YAML file rather than the packaged
|
||||
`application.yml`:
|
||||
|
||||
```yaml
|
||||
cygnus:
|
||||
security:
|
||||
enabled: true
|
||||
issuer-uri: https://cloud.example.com
|
||||
audience: cygnus-cloud-api
|
||||
token-audience: https://cloud.example.com/oauth2/token
|
||||
clients:
|
||||
customer-a:
|
||||
enabled: true
|
||||
installation-id: site-01
|
||||
assertion-public-key: file:/secure/clients/customer-a/public.pem
|
||||
scopes:
|
||||
- identity.login
|
||||
```
|
||||
|
||||
Start with that protected file using
|
||||
`--spring.config.additional-location=file:/secure/cygnus/clients.yml`.
|
||||
Never place cloud private keys, customer assertions, or installation private
|
||||
keys in the repository or container image.
|
||||
84
cygnus-cloud-service/pom.xml
Normal file
84
cygnus-cloud-service/pom.xml
Normal file
@@ -0,0 +1,84 @@
|
||||
<?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-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vertx</groupId>
|
||||
<artifactId>vertx-pg-client</artifactId>
|
||||
<version>${vertx.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cygnus.cloud;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class CygnusCloudServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CygnusCloudServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
9
cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/CacheProperties.java
vendored
Normal file
9
cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/CacheProperties.java
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.cygnus.cloud.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties("cygnus.cache")
|
||||
public record CacheProperties(String keyPrefix, Duration defaultTtl) {
|
||||
}
|
||||
44
cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/ReactiveCacheService.java
vendored
Normal file
44
cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/ReactiveCacheService.java
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
}
|
||||
|
||||
private String cacheKey(String namespace, String key) {
|
||||
return properties.keyPrefix() + ':' + namespace + ':' + key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.cloud.database;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties("cygnus.database")
|
||||
public record DatabaseProperties(
|
||||
String host,
|
||||
int port,
|
||||
String database,
|
||||
String username,
|
||||
String password,
|
||||
boolean ssl,
|
||||
Duration connectTimeout,
|
||||
int poolSize,
|
||||
int poolWaitQueueSize) {
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.cygnus.cloud.database;
|
||||
|
||||
import io.vertx.sqlclient.Pool;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.RowSet;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.cygnus.cloud.database;
|
||||
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.pgclient.PgConnectOptions;
|
||||
import io.vertx.pgclient.PgBuilder;
|
||||
import io.vertx.sqlclient.Pool;
|
||||
import io.vertx.sqlclient.PoolOptions;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class VertxDatabaseConfiguration {
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
Vertx vertx() {
|
||||
return Vertx.vertx();
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
Pool postgresPool(Vertx vertx, DatabaseProperties properties) {
|
||||
PgConnectOptions connection = new PgConnectOptions()
|
||||
.setHost(properties.host())
|
||||
.setPort(properties.port())
|
||||
.setDatabase(properties.database())
|
||||
.setUser(properties.username())
|
||||
.setPassword(properties.password())
|
||||
.setSslMode(properties.ssl()
|
||||
? io.vertx.pgclient.SslMode.REQUIRE
|
||||
: io.vertx.pgclient.SslMode.DISABLE);
|
||||
|
||||
PoolOptions pool = new PoolOptions()
|
||||
.setMaxSize(properties.poolSize())
|
||||
.setMaxWaitQueueSize(properties.poolWaitQueueSize())
|
||||
.setConnectionTimeout(Math.toIntExact(properties.connectTimeout().toMillis()))
|
||||
.setConnectionTimeoutUnit(TimeUnit.MILLISECONDS);
|
||||
|
||||
return PgBuilder.pool()
|
||||
.using(vertx)
|
||||
.connectingTo(connection)
|
||||
.with(pool)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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 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(
|
||||
payload.loginId(),
|
||||
payload.password(),
|
||||
remoteAddress(serverRequest));
|
||||
});
|
||||
}
|
||||
|
||||
private void validatePayload(LoginPayload payload) {
|
||||
if (payload == null
|
||||
|| !StringUtils.hasText(payload.loginId())
|
||||
|| !StringUtils.hasText(payload.password())
|
||||
|| !StringUtils.hasText(payload.clientId())
|
||||
|| !StringUtils.hasText(payload.installationId())
|
||||
|| !StringUtils.hasText(payload.nonce())
|
||||
|| payload.issuedAt() == null) {
|
||||
throw new AuthenticationException("Invalid login payload");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMachineBinding(Jwt jwt, LoginPayload payload) {
|
||||
String authenticatedClient = jwt.getClaimAsString("client_id");
|
||||
String authenticatedInstallation = jwt.getClaimAsString("installation_id");
|
||||
if (!payload.clientId().equals(authenticatedClient)
|
||||
|| !payload.installationId().equals(authenticatedInstallation)) {
|
||||
throw new AuthenticationException("Machine identity mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateFreshness(LoginPayload payload) {
|
||||
Duration age = Duration.between(payload.issuedAt(), clock.instant()).abs();
|
||||
if (age.compareTo(encryptionProperties.payloadTtl()) > 0) {
|
||||
throw new AuthenticationException("Login request expired");
|
||||
}
|
||||
}
|
||||
|
||||
private String remoteAddress(ServerHttpRequest request) {
|
||||
return request.getRemoteAddress() == null
|
||||
? null
|
||||
: request.getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record EncryptedLoginRequest(
|
||||
@NotBlank String keyId,
|
||||
@NotBlank String encryptedKey,
|
||||
@NotBlank String initializationVector,
|
||||
@NotBlank String encryptedPayload) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import com.cygnus.cloud.identity.service.AuthenticationException;
|
||||
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 IdentityErrorHandler {
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
Map<String, String> authenticationFailure() {
|
||||
return Map.of("code", "AUTHENTICATION_FAILED", "message", "Authentication failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties("cygnus.login-encryption")
|
||||
public record LoginEncryptionProperties(
|
||||
String keyId,
|
||||
String privateKeyLocation,
|
||||
Duration payloadTtl) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginPayload(
|
||||
@NotBlank String loginId,
|
||||
@NotBlank String password,
|
||||
@NotBlank String clientId,
|
||||
@NotBlank String installationId,
|
||||
@NotBlank String nonce,
|
||||
@NotNull Instant issuedAt) {
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.cygnus.cloud.identity.api;
|
||||
|
||||
import com.cygnus.cloud.identity.service.AuthenticationException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.OAEPParameterSpec;
|
||||
import javax.crypto.spec.PSource;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Component
|
||||
public class RsaLoginPayloadDecryptor {
|
||||
|
||||
private static final OAEPParameterSpec OAEP_SHA_256 = new OAEPParameterSpec(
|
||||
"SHA-256",
|
||||
"MGF1",
|
||||
MGF1ParameterSpec.SHA256,
|
||||
PSource.PSpecified.DEFAULT);
|
||||
|
||||
private final LoginEncryptionProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private volatile PrivateKey privateKey;
|
||||
|
||||
public RsaLoginPayloadDecryptor(
|
||||
LoginEncryptionProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
ResourceLoader resourceLoader) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.resourceLoader = resourceLoader;
|
||||
}
|
||||
|
||||
public LoginPayload decrypt(EncryptedLoginRequest request) {
|
||||
if (!properties.keyId().equals(request.keyId())) {
|
||||
throw new AuthenticationException("Unsupported encryption key");
|
||||
}
|
||||
try {
|
||||
Cipher keyCipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
|
||||
keyCipher.init(Cipher.DECRYPT_MODE, privateKey(), OAEP_SHA_256);
|
||||
byte[] aesKey =
|
||||
keyCipher.doFinal(Base64.getDecoder().decode(request.encryptedKey()));
|
||||
|
||||
Cipher payloadCipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
payloadCipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(aesKey, "AES"),
|
||||
new GCMParameterSpec(
|
||||
128,
|
||||
Base64.getDecoder().decode(request.initializationVector())));
|
||||
payloadCipher.updateAAD(request.keyId().getBytes(StandardCharsets.UTF_8));
|
||||
byte[] plaintext = payloadCipher.doFinal(
|
||||
Base64.getDecoder().decode(request.encryptedPayload()));
|
||||
return objectMapper.readValue(plaintext, LoginPayload.class);
|
||||
} catch (AuthenticationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new AuthenticationException("Invalid encrypted login request");
|
||||
}
|
||||
}
|
||||
|
||||
private PrivateKey privateKey() throws Exception {
|
||||
PrivateKey loaded = privateKey;
|
||||
if (loaded != null) {
|
||||
return loaded;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (privateKey == null) {
|
||||
Resource resource =
|
||||
resourceLoader.getResource(properties.privateKeyLocation());
|
||||
String pem = resource.getContentAsString(StandardCharsets.US_ASCII);
|
||||
String encoded = pem
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
byte[] keyBytes = Base64.getDecoder().decode(encoded);
|
||||
privateKey = KeyFactory.getInstance("RSA")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
||||
}
|
||||
return privateKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.cygnus.cloud.identity.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record AuthenticatedIdentity(
|
||||
short userId,
|
||||
String loginId,
|
||||
String displayName,
|
||||
short groupId,
|
||||
String groupName,
|
||||
short branchId,
|
||||
String branchName,
|
||||
String branchCode,
|
||||
String branchLocation,
|
||||
short companyId,
|
||||
String companyName,
|
||||
String companyCode,
|
||||
Instant loginTime,
|
||||
List<MenuItem> menu) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.cloud.identity.model;
|
||||
|
||||
public record IdentityUser(
|
||||
short userId,
|
||||
String loginId,
|
||||
String displayName,
|
||||
String legacyPassword,
|
||||
short groupId,
|
||||
String groupName,
|
||||
short branchId,
|
||||
String branchName,
|
||||
String branchCode,
|
||||
String branchLocation,
|
||||
short companyId,
|
||||
String companyName,
|
||||
String companyCode,
|
||||
boolean active) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.cloud.identity.model;
|
||||
|
||||
public record MenuItem(
|
||||
short pageId,
|
||||
String label,
|
||||
String targetUrl,
|
||||
short parentPage,
|
||||
short pageOrder,
|
||||
String permission,
|
||||
String targetWindow,
|
||||
String requestValue) {
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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 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.group_id = u.group_id
|
||||
JOIN identity.company c ON c.company_id = u.company_id
|
||||
JOIN identity.company_branch b
|
||||
ON b.branch_id = u.branch_id AND b.company_id = u.company_id
|
||||
WHERE upper(u.loginid) = upper($1)
|
||||
""";
|
||||
|
||||
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.group_id = $1
|
||||
AND p.isvisible = 1
|
||||
AND permissions.permission <> '000'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM identity.denied_pages denied
|
||||
WHERE denied.user_id = $2
|
||||
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
|
||||
(loginid, logintime, ipaddr, user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
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(String loginId) {
|
||||
return database.preparedQuery(FIND_USER, Tuple.of(loginId))
|
||||
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user));
|
||||
}
|
||||
|
||||
public Mono<List<MenuItem>> findMenu(short groupId, short userId) {
|
||||
return database.preparedQuery(FIND_MENU, Tuple.of(groupId, userId))
|
||||
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::menuItem))
|
||||
.collectList();
|
||||
}
|
||||
|
||||
public Mono<Long> recordLogin(
|
||||
String loginId, Instant loginTime, String remoteAddress, short userId) {
|
||||
LocalDateTime databaseTime = LocalDateTime.ofInstant(loginTime, ZoneOffset.UTC);
|
||||
return database.preparedQuery(
|
||||
RECORD_LOGIN,
|
||||
Tuple.of(loginId, databaseTime, remoteAddress, userId))
|
||||
.map(rows -> rows.iterator().next().getLong("uid"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.cygnus.cloud.identity.repository;
|
||||
|
||||
import com.cygnus.cloud.identity.model.IdentityUser;
|
||||
import com.cygnus.cloud.identity.model.MenuItem;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class IdentityRowMapper {
|
||||
|
||||
IdentityUser user(Row row) {
|
||||
return new IdentityUser(
|
||||
row.getShort("user_id"),
|
||||
row.getString("loginid"),
|
||||
row.getString("displayname"),
|
||||
row.getString("loginpassword"),
|
||||
row.getShort("group_id"),
|
||||
row.getString("group_name"),
|
||||
row.getShort("branch_id"),
|
||||
row.getString("branchname"),
|
||||
row.getString("branchcode"),
|
||||
row.getString("city"),
|
||||
row.getShort("company_id"),
|
||||
row.getString("companyname"),
|
||||
row.getString("companycode"),
|
||||
row.getShort("isactive") == 1);
|
||||
}
|
||||
|
||||
MenuItem menuItem(Row row) {
|
||||
return new MenuItem(
|
||||
row.getShort("page_id"),
|
||||
row.getString("menulabel"),
|
||||
row.getString("targeturl"),
|
||||
row.getShort("parentpage"),
|
||||
row.getShort("pageorder"),
|
||||
row.getString("permission").trim(),
|
||||
row.getString("targetwindow"),
|
||||
row.getString("requestval"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.identity.service;
|
||||
|
||||
public class AuthenticationException extends RuntimeException {
|
||||
|
||||
public AuthenticationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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 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(
|
||||
String loginId, String password, String remoteAddress) {
|
||||
return repository.findUsersByLoginId(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(user.groupId(), user.userId())
|
||||
.flatMap(menu -> repository.recordLogin(
|
||||
user.loginId(), loginTime, remoteAddress, user.userId())
|
||||
.thenReturn(toAuthenticatedIdentity(user, loginTime, menu)));
|
||||
});
|
||||
}
|
||||
|
||||
private AuthenticatedIdentity toAuthenticatedIdentity(
|
||||
IdentityUser user,
|
||||
Instant loginTime,
|
||||
java.util.List<com.cygnus.cloud.identity.model.MenuItem> menu) {
|
||||
return new AuthenticatedIdentity(
|
||||
user.userId(),
|
||||
user.loginId(),
|
||||
user.displayName(),
|
||||
user.groupId(),
|
||||
user.groupName(),
|
||||
user.branchId(),
|
||||
user.branchName(),
|
||||
user.branchCode(),
|
||||
user.branchLocation(),
|
||||
user.companyId(),
|
||||
user.companyName(),
|
||||
user.companyCode(),
|
||||
loginTime,
|
||||
menu);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.cygnus.cloud.identity.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Temporary compatibility verifier for passwords created by the legacy app.
|
||||
* New passwords must not be written with this algorithm.
|
||||
*/
|
||||
@Component
|
||||
public class LegacyPasswordVerifier {
|
||||
|
||||
private static final byte[] LEGACY_KEY =
|
||||
"ThisIsASecretKey".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
public boolean matches(String rawPassword, String storedPassword) {
|
||||
if (rawPassword == null || storedPassword == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(LEGACY_KEY, "AES"));
|
||||
byte[] encrypted = cipher.doFinal(rawPassword.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] expected = Base64.getMimeDecoder().decode(storedPassword);
|
||||
return MessageDigest.isEqual(encrypted, expected);
|
||||
} catch (GeneralSecurityException | IllegalArgumentException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.cygnus.cloud.identity.service;
|
||||
|
||||
import com.cygnus.cloud.cache.ReactiveCacheService;
|
||||
import java.time.Duration;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class LoginRequestReplayService {
|
||||
|
||||
private static final String NAMESPACE = "login-nonce";
|
||||
|
||||
private final ReactiveCacheService cache;
|
||||
|
||||
public LoginRequestReplayService(ReactiveCacheService cache) {
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
public Mono<Boolean> claim(String installationId, String nonce, Duration ttl) {
|
||||
return cache.putIfAbsent(
|
||||
NAMESPACE, installationId + ':' + nonce, "used", ttl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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("scope", String.join(" ", scopes))
|
||||
.build();
|
||||
SignedJWT jwt = new SignedJWT(
|
||||
new JWSHeader.Builder(JWSAlgorithm.RS256)
|
||||
.keyID("cygnus-access-token")
|
||||
.build(),
|
||||
claims);
|
||||
jwt.sign(new RSASSASigner(
|
||||
PemKeyLoader.privateKey(properties.accessTokenPrivateKey())));
|
||||
return new TokenResponse(
|
||||
jwt.serialize(),
|
||||
"Bearer",
|
||||
properties.accessTokenTtl().toSeconds(),
|
||||
String.join(" ", scopes));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("Unable to issue access token", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private static final OAuth2Error INVALID_AUDIENCE =
|
||||
new OAuth2Error("invalid_token", "Required token audience is missing", null);
|
||||
|
||||
private final String audience;
|
||||
|
||||
AudienceValidator(String audience) {
|
||||
this.audience = audience;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
return jwt.getAudience().contains(audience)
|
||||
? OAuth2TokenValidatorResult.success()
|
||||
: OAuth2TokenValidatorResult.failure(INVALID_AUDIENCE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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 java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
class ClientAssertionValidator {
|
||||
|
||||
private final CommunicationSecurityProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
ClientAssertionValidator(CommunicationSecurityProperties properties, Clock clock) {
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
MachineClientPrincipal validate(String clientId, String encryptedAssertion) {
|
||||
CommunicationSecurityProperties.MachineClient client =
|
||||
properties.clients() == null ? null : properties.clients().get(clientId);
|
||||
if (client == null || !client.enabled()) {
|
||||
throw invalid();
|
||||
}
|
||||
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());
|
||||
if (!signedJwt.verify(new RSASSAVerifier(
|
||||
PemKeyLoader.publicKey(client.assertionPublicKey())))) {
|
||||
throw invalid();
|
||||
}
|
||||
|
||||
JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
|
||||
validateClaims(clientId, client, claims);
|
||||
return new MachineClientPrincipal(
|
||||
clientId, client.installationId(), client.scopes());
|
||||
} catch (MachineAuthenticationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new MachineAuthenticationException("Invalid client assertion", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateClaims(
|
||||
String clientId,
|
||||
CommunicationSecurityProperties.MachineClient client,
|
||||
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())
|
||||
|| !client.installationId().equals(
|
||||
claims.getStringClaim("installation_id"))
|
||||
|| issuedAt == null
|
||||
|| expiresAt == null
|
||||
|| now.isBefore(issuedAt.toInstant().minusSeconds(60))
|
||||
|| !now.isBefore(expiresAt.toInstant())) {
|
||||
throw invalid();
|
||||
}
|
||||
Duration lifetime = Duration.between(
|
||||
issuedAt.toInstant(), expiresAt.toInstant());
|
||||
if (lifetime.isNegative() || lifetime.compareTo(properties.assertionTtl()) > 0) {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private MachineAuthenticationException invalid() {
|
||||
return new MachineAuthenticationException("Invalid client assertion");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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/identity/login")
|
||||
.hasAuthority("SCOPE_identity.login")
|
||||
.anyExchange().authenticated())
|
||||
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "cygnus.security.enabled", havingValue = "true")
|
||||
ReactiveJwtDecoder reactiveJwtDecoder(CommunicationSecurityProperties properties) {
|
||||
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
|
||||
.withPublicKey(PemKeyLoader.publicKey(properties.accessTokenPublicKey()))
|
||||
.build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<Jwt>(
|
||||
JwtValidators.createDefaultWithIssuer(properties.issuerUri()),
|
||||
new AudienceValidator(properties.audience())));
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
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,
|
||||
Map<String, MachineClient> clients) {
|
||||
|
||||
public record MachineClient(
|
||||
boolean enabled,
|
||||
String installationId,
|
||||
String assertionPublicKey,
|
||||
Set<String> scopes) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import com.cygnus.cloud.cache.ReactiveCacheService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class JwtReplayProtectionService {
|
||||
|
||||
private static final String NAMESPACE = "jwt-jti";
|
||||
|
||||
private final ReactiveCacheService cache;
|
||||
|
||||
public JwtReplayProtectionService(ReactiveCacheService cache) {
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
public Mono<Boolean> claim(String jwtId, Duration remainingLifetime) {
|
||||
return cache.putIfAbsent(NAMESPACE, jwtId, "used", remainingLifetime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
final class MachineAuthenticationException extends RuntimeException {
|
||||
|
||||
MachineAuthenticationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
MachineAuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
record MachineClientPrincipal(
|
||||
String clientId,
|
||||
String installationId,
|
||||
Set<String> allowedScopes) {
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
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());
|
||||
|
||||
Map<String, CommunicationSecurityProperties.MachineClient> clients =
|
||||
properties.clients();
|
||||
if (clients == null || clients.isEmpty()) {
|
||||
throw invalid("at least one machine client is required");
|
||||
}
|
||||
clients.forEach(this::validateClient);
|
||||
}
|
||||
|
||||
private void validateClient(
|
||||
String clientId,
|
||||
CommunicationSecurityProperties.MachineClient client) {
|
||||
requireText("clients.<client-id>", clientId);
|
||||
if (client == null) {
|
||||
throw invalid("client '" + clientId + "' has no configuration");
|
||||
}
|
||||
requireText(
|
||||
"clients." + clientId + ".installation-id",
|
||||
client.installationId());
|
||||
requireText(
|
||||
"clients." + clientId + ".assertion-public-key",
|
||||
client.assertionPublicKey());
|
||||
if (client.scopes() == null
|
||||
|| client.scopes().isEmpty()
|
||||
|| client.scopes().stream().anyMatch(this::isBlank)) {
|
||||
throw invalid(
|
||||
"clients." + clientId + ".scopes must contain valid scopes");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireUri(String name, String value) {
|
||||
requireText(name, value);
|
||||
try {
|
||||
URI uri = URI.create(value);
|
||||
if (!uri.isAbsolute()) {
|
||||
throw invalid(name + " must be an absolute URI");
|
||||
}
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(name + " must be a valid absolute URI");
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePositive(String name, Duration value) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw invalid(name + " must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireText(String name, String value) {
|
||||
if (isBlank(value)) {
|
||||
throw invalid(name + " is required");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private IllegalStateException invalid(String detail) {
|
||||
return new IllegalStateException(
|
||||
"Invalid cygnus.security configuration: " + detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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().map(this::issueToken);
|
||||
}
|
||||
|
||||
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");
|
||||
MachineClientPrincipal principal = assertionValidator.validate(
|
||||
clientId, required(form, "client_assertion"));
|
||||
Set<String> requestedScopes = scopes(form.getFirst("scope"));
|
||||
if (requestedScopes.isEmpty()
|
||||
|| !principal.allowedScopes().containsAll(requestedScopes)) {
|
||||
throw new MachineAuthenticationException("Invalid requested scope");
|
||||
}
|
||||
return tokenIssuer.issue(principal, requestedScopes).asOAuthResponse();
|
||||
}
|
||||
|
||||
private String required(MultiValueMap<String, String> form, String name) {
|
||||
String value = form.getFirst(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new MachineAuthenticationException("Invalid token request");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private Set<String> scopes(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
return new LinkedHashSet<>(Arrays.asList(value.trim().split("\\s+")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice(assignableTypes = MachineTokenController.class)
|
||||
class MachineTokenErrorHandler {
|
||||
|
||||
@ExceptionHandler(MachineAuthenticationException.class)
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
Map<String, String> invalidClient() {
|
||||
return Map.of("error", "invalid_client");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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.startsWith("classpath:")) {
|
||||
String resource = location.substring("classpath:".length());
|
||||
try (InputStream stream = Thread.currentThread()
|
||||
.getContextClassLoader()
|
||||
.getResourceAsStream(resource)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalArgumentException("Key resource not found");
|
||||
}
|
||||
return new String(stream.readAllBytes(), StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
String file = location.startsWith("file:") ? location.substring(5) : location;
|
||||
return Files.readString(Path.of(file), StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
record TokenResponse(
|
||||
String accessToken,
|
||||
String tokenType,
|
||||
long expiresIn,
|
||||
String scope) {
|
||||
|
||||
Map<String, Object> asOAuthResponse() {
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("access_token", accessToken);
|
||||
response.put("token_type", tokenType);
|
||||
response.put("expires_in", expiresIn);
|
||||
response.put("scope", scope);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cygnus.cloud.system;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class SystemConfiguration {
|
||||
|
||||
@Bean
|
||||
Clock systemClock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cygnus.cloud.system;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/system")
|
||||
public class SystemInfoController {
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
public SystemInfoController(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@GetMapping("/info")
|
||||
public Mono<SystemInfoResponse> info() {
|
||||
return Mono.just(new SystemInfoResponse(
|
||||
"cygnus-cloud-service",
|
||||
"1.0.0-SNAPSHOT",
|
||||
Instant.now(clock)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.cygnus.cloud.system;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record SystemInfoResponse(String service, String version, Instant timestamp) {
|
||||
}
|
||||
64
cygnus-cloud-service/src/main/resources/application.yml
Normal file
64
cygnus-cloud-service/src/main/resources/application.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
spring:
|
||||
application:
|
||||
name: cygnus-cloud-service
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:192.168.0.111}
|
||||
port: ${REDIS_PORT:7901}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
connect-timeout: ${REDIS_CONNECT_TIMEOUT:3s}
|
||||
timeout: ${REDIS_COMMAND_TIMEOUT:3s}
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: ${REDIS_MAX_ACTIVE:20}
|
||||
max-idle: ${REDIS_MAX_IDLE:10}
|
||||
min-idle: ${REDIS_MIN_IDLE:1}
|
||||
max-wait: ${REDIS_MAX_WAIT:2s}
|
||||
|
||||
cygnus:
|
||||
database:
|
||||
host: ${DB_HOST:192.168.0.111}
|
||||
port: ${DB_PORT:5432}
|
||||
database: ${DB_NAME:matrix}
|
||||
username: ${DB_USER:postgres}
|
||||
password: ${DB_PASSWORD:}
|
||||
ssl: ${DB_SSL:false}
|
||||
connect-timeout: ${DB_CONNECT_TIMEOUT:3s}
|
||||
pool-size: ${DB_POOL_SIZE:20}
|
||||
pool-wait-queue-size: ${DB_POOL_WAIT_QUEUE_SIZE:100}
|
||||
security:
|
||||
enabled: ${CYGNUS_SECURITY_ENABLED:false}
|
||||
issuer-uri: ${CYGNUS_JWT_ISSUER_URI:http://localhost:8090}
|
||||
audience: ${CYGNUS_JWT_AUDIENCE:cygnus-cloud-api}
|
||||
token-audience: ${CYGNUS_TOKEN_AUDIENCE:http://localhost:8090/oauth2/token}
|
||||
assertion-ttl: ${CYGNUS_ASSERTION_TTL:370d}
|
||||
access-token-ttl: ${CYGNUS_ACCESS_TOKEN_TTL:20m}
|
||||
assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem}
|
||||
access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem}
|
||||
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
|
||||
clients: {}
|
||||
login-encryption:
|
||||
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
|
||||
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
|
||||
payload-ttl: ${CYGNUS_LOGIN_PAYLOAD_TTL:5m}
|
||||
cache:
|
||||
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
|
||||
default-ttl: ${CYGNUS_CACHE_TTL:10m}
|
||||
|
||||
server:
|
||||
port: ${CYGNUS_CLOUD_PORT:8090}
|
||||
shutdown: graceful
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
|
||||
info:
|
||||
app:
|
||||
name: ${spring.application.name}
|
||||
version: 1.0.0-SNAPSHOT
|
||||
@@ -0,0 +1,217 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS identity;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.company
|
||||
(LIKE public.company INCLUDING ALL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.company_branch
|
||||
(LIKE public.company_branch INCLUDING ALL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.user_group
|
||||
(LIKE public.user_group INCLUDING ALL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.app_user
|
||||
(LIKE public.app_user INCLUDING ALL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.pages
|
||||
(LIKE public.pages INCLUDING ALL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.permission
|
||||
(LIKE public.permission INCLUDING ALL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.denied_pages
|
||||
(LIKE public.denied_pages INCLUDING ALL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.user_loginhistory
|
||||
(LIKE public.user_loginhistory INCLUDING ALL);
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS identity.app_user_user_id_seq
|
||||
AS smallint MAXVALUE 32767;
|
||||
CREATE SEQUENCE IF NOT EXISTS identity.company_company_id_seq
|
||||
AS smallint MAXVALUE 32767;
|
||||
CREATE SEQUENCE IF NOT EXISTS identity.company_branch_branch_id_seq
|
||||
AS smallint MAXVALUE 32767;
|
||||
CREATE SEQUENCE IF NOT EXISTS identity.user_group_group_id_seq
|
||||
AS smallint MAXVALUE 32767;
|
||||
CREATE SEQUENCE IF NOT EXISTS identity.pages_page_id_seq
|
||||
AS smallint MAXVALUE 32767;
|
||||
CREATE SEQUENCE IF NOT EXISTS identity.permission_permission_id_seq
|
||||
AS integer;
|
||||
CREATE SEQUENCE IF NOT EXISTS identity.denied_pages_uid_seq
|
||||
AS integer;
|
||||
CREATE SEQUENCE IF NOT EXISTS identity.user_loginhistory_uid_seq
|
||||
AS bigint;
|
||||
|
||||
ALTER TABLE identity.app_user
|
||||
ALTER COLUMN user_id SET DEFAULT nextval('identity.app_user_user_id_seq');
|
||||
ALTER TABLE identity.company
|
||||
ALTER COLUMN company_id SET DEFAULT nextval('identity.company_company_id_seq');
|
||||
ALTER TABLE identity.company_branch
|
||||
ALTER COLUMN branch_id SET DEFAULT nextval('identity.company_branch_branch_id_seq');
|
||||
ALTER TABLE identity.user_group
|
||||
ALTER COLUMN group_id SET DEFAULT nextval('identity.user_group_group_id_seq');
|
||||
ALTER TABLE identity.pages
|
||||
ALTER COLUMN page_id SET DEFAULT nextval('identity.pages_page_id_seq');
|
||||
ALTER TABLE identity.permission
|
||||
ALTER COLUMN permission_id SET DEFAULT nextval('identity.permission_permission_id_seq');
|
||||
ALTER TABLE identity.denied_pages
|
||||
ALTER COLUMN uid SET DEFAULT nextval('identity.denied_pages_uid_seq');
|
||||
ALTER TABLE identity.user_loginhistory
|
||||
ALTER COLUMN uid SET DEFAULT nextval('identity.user_loginhistory_uid_seq');
|
||||
ALTER TABLE identity.user_loginhistory
|
||||
ALTER COLUMN ipaddr TYPE character varying(45);
|
||||
|
||||
INSERT INTO identity.company
|
||||
SELECT * FROM public.company
|
||||
ON CONFLICT (company_id) DO UPDATE SET
|
||||
companyname = EXCLUDED.companyname,
|
||||
companycode = EXCLUDED.companycode,
|
||||
servicetaxno = EXCLUDED.servicetaxno,
|
||||
panno = EXCLUDED.panno,
|
||||
createdon = EXCLUDED.createdon,
|
||||
isactive = EXCLUDED.isactive,
|
||||
cinno = EXCLUDED.cinno;
|
||||
|
||||
INSERT INTO identity.company_branch
|
||||
SELECT * FROM public.company_branch
|
||||
ON CONFLICT (branch_id) DO UPDATE SET
|
||||
company_id = EXCLUDED.company_id,
|
||||
branchname = EXCLUDED.branchname,
|
||||
branchcode = EXCLUDED.branchcode,
|
||||
address1 = EXCLUDED.address1,
|
||||
address2 = EXCLUDED.address2,
|
||||
address3 = EXCLUDED.address3,
|
||||
city = EXCLUDED.city,
|
||||
pincode = EXCLUDED.pincode,
|
||||
landlineno = EXCLUDED.landlineno,
|
||||
faxno = EXCLUDED.faxno,
|
||||
emailid = EXCLUDED.emailid,
|
||||
contactperson1 = EXCLUDED.contactperson1,
|
||||
contactno1 = EXCLUDED.contactno1,
|
||||
emailid1 = EXCLUDED.emailid1,
|
||||
contactperson2 = EXCLUDED.contactperson2,
|
||||
contactno2 = EXCLUDED.contactno2,
|
||||
emailid2 = EXCLUDED.emailid2,
|
||||
isactive = EXCLUDED.isactive,
|
||||
createdon = EXCLUDED.createdon,
|
||||
gstin = EXCLUDED.gstin,
|
||||
cgst = EXCLUDED.cgst,
|
||||
sgst = EXCLUDED.sgst,
|
||||
igst = EXCLUDED.igst;
|
||||
|
||||
INSERT INTO identity.user_group
|
||||
SELECT * FROM public.user_group
|
||||
ON CONFLICT (group_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
department_id = EXCLUDED.department_id,
|
||||
createdon = EXCLUDED.createdon,
|
||||
createdby = EXCLUDED.createdby,
|
||||
lasteditedon = EXCLUDED.lasteditedon,
|
||||
lasteditedby = EXCLUDED.lasteditedby,
|
||||
isactive = EXCLUDED.isactive;
|
||||
|
||||
INSERT INTO identity.app_user
|
||||
SELECT * FROM public.app_user
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
loginid = EXCLUDED.loginid,
|
||||
displayname = EXCLUDED.displayname,
|
||||
loginpassword = EXCLUDED.loginpassword,
|
||||
group_id = EXCLUDED.group_id,
|
||||
createdon = EXCLUDED.createdon,
|
||||
createdby = EXCLUDED.createdby,
|
||||
activatedon = EXCLUDED.activatedon,
|
||||
activatedby = EXCLUDED.activatedby,
|
||||
lasteditedon = EXCLUDED.lasteditedon,
|
||||
lasteditedby = EXCLUDED.lasteditedby,
|
||||
branch_id = EXCLUDED.branch_id,
|
||||
company_id = EXCLUDED.company_id,
|
||||
isactive = EXCLUDED.isactive,
|
||||
team_id = EXCLUDED.team_id,
|
||||
emailid = EXCLUDED.emailid,
|
||||
entry_time = EXCLUDED.entry_time;
|
||||
|
||||
INSERT INTO identity.pages
|
||||
SELECT * FROM public.pages
|
||||
ON CONFLICT (page_id) DO UPDATE SET
|
||||
menulabel = EXCLUDED.menulabel,
|
||||
targeturl = EXCLUDED.targeturl,
|
||||
parentpage = EXCLUDED.parentpage,
|
||||
targetwindow = EXCLUDED.targetwindow,
|
||||
pageorder = EXCLUDED.pageorder,
|
||||
isvisible = EXCLUDED.isvisible;
|
||||
|
||||
INSERT INTO identity.permission
|
||||
SELECT * FROM public.permission
|
||||
ON CONFLICT (permission_id) DO UPDATE SET
|
||||
page_id = EXCLUDED.page_id,
|
||||
group_id = EXCLUDED.group_id,
|
||||
lasteditedon = EXCLUDED.lasteditedon,
|
||||
lasteditedby = EXCLUDED.lasteditedby,
|
||||
permission = EXCLUDED.permission,
|
||||
requestval = EXCLUDED.requestval;
|
||||
|
||||
INSERT INTO identity.denied_pages
|
||||
SELECT * FROM public.denied_pages
|
||||
ON CONFLICT (uid) DO UPDATE SET
|
||||
page_id = EXCLUDED.page_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
isdenied = EXCLUDED.isdenied;
|
||||
|
||||
INSERT INTO identity.user_loginhistory
|
||||
SELECT * FROM public.user_loginhistory
|
||||
ON CONFLICT (uid) DO UPDATE SET
|
||||
loginid = EXCLUDED.loginid,
|
||||
logintime = EXCLUDED.logintime,
|
||||
logouttime = EXCLUDED.logouttime,
|
||||
ipaddr = EXCLUDED.ipaddr,
|
||||
user_id = EXCLUDED.user_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_app_user_loginid_ci
|
||||
ON identity.app_user (upper(loginid));
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_permission_group_page
|
||||
ON identity.permission (group_id, page_id)
|
||||
WHERE permission <> '000';
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_pages_menu
|
||||
ON identity.pages (parentpage, pageorder DESC)
|
||||
WHERE isvisible = 1;
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_denied_pages_user_page
|
||||
ON identity.denied_pages (user_id, page_id)
|
||||
WHERE isdenied = 1;
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_login_history_user_time
|
||||
ON identity.user_loginhistory (user_id, logintime DESC);
|
||||
|
||||
SELECT setval(
|
||||
'identity.app_user_user_id_seq',
|
||||
GREATEST(COALESCE((SELECT max(user_id) FROM identity.app_user), 1), 1),
|
||||
EXISTS (SELECT 1 FROM identity.app_user));
|
||||
SELECT setval(
|
||||
'identity.company_company_id_seq',
|
||||
GREATEST(COALESCE((SELECT max(company_id) FROM identity.company), 1), 1),
|
||||
EXISTS (SELECT 1 FROM identity.company));
|
||||
SELECT setval(
|
||||
'identity.company_branch_branch_id_seq',
|
||||
GREATEST(COALESCE((SELECT max(branch_id) FROM identity.company_branch), 1), 1),
|
||||
EXISTS (SELECT 1 FROM identity.company_branch));
|
||||
SELECT setval(
|
||||
'identity.user_group_group_id_seq',
|
||||
GREATEST(COALESCE((SELECT max(group_id) FROM identity.user_group), 1), 1),
|
||||
EXISTS (SELECT 1 FROM identity.user_group));
|
||||
SELECT setval(
|
||||
'identity.pages_page_id_seq',
|
||||
GREATEST(COALESCE((SELECT max(page_id) FROM identity.pages), 1), 1),
|
||||
EXISTS (SELECT 1 FROM identity.pages));
|
||||
SELECT setval(
|
||||
'identity.permission_permission_id_seq',
|
||||
GREATEST(COALESCE((SELECT max(permission_id) FROM identity.permission), 1), 1),
|
||||
EXISTS (SELECT 1 FROM identity.permission));
|
||||
SELECT setval(
|
||||
'identity.denied_pages_uid_seq',
|
||||
GREATEST(COALESCE((SELECT max(uid) FROM identity.denied_pages), 1), 1),
|
||||
EXISTS (SELECT 1 FROM identity.denied_pages));
|
||||
SELECT setval(
|
||||
'identity.user_loginhistory_uid_seq',
|
||||
GREATEST(COALESCE((SELECT max(uid) FROM identity.user_loginhistory), 1), 1),
|
||||
EXISTS (SELECT 1 FROM identity.user_loginhistory));
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.cygnus.cloud;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.cygnus.cloud.cache.ReactiveCacheService;
|
||||
import com.cygnus.cloud.database.DatabaseProperties;
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.security.CommunicationSecurityProperties;
|
||||
import io.vertx.sqlclient.Pool;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "cygnus.security.enabled=false")
|
||||
class InfrastructureConfigurationTest {
|
||||
|
||||
@Autowired
|
||||
private Pool postgresPool;
|
||||
|
||||
@Autowired
|
||||
private ReactiveDatabaseClient databaseClient;
|
||||
|
||||
@Autowired
|
||||
private ReactiveCacheService cacheService;
|
||||
|
||||
@Autowired
|
||||
private DatabaseProperties databaseProperties;
|
||||
|
||||
@Autowired
|
||||
private CommunicationSecurityProperties securityProperties;
|
||||
|
||||
@Test
|
||||
void communicationInfrastructureStartsWithoutOpeningExternalConnections() {
|
||||
assertThat(postgresPool).isNotNull();
|
||||
assertThat(databaseClient).isNotNull();
|
||||
assertThat(cacheService).isNotNull();
|
||||
assertThat(databaseProperties.database()).isEqualTo("matrix");
|
||||
assertThat(securityProperties.audience()).isEqualTo("cygnus-cloud-api");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.cygnus.cloud.identity.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cygnus.cloud.identity.model.IdentityUser;
|
||||
import com.cygnus.cloud.identity.model.MenuItem;
|
||||
import com.cygnus.cloud.identity.repository.IdentityRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IdentityAuthenticationServiceTest {
|
||||
|
||||
private static final Instant LOGIN_TIME = Instant.parse("2026-07-23T06:30:00Z");
|
||||
|
||||
@Mock
|
||||
private IdentityRepository repository;
|
||||
|
||||
@Mock
|
||||
private LegacyPasswordVerifier passwordVerifier;
|
||||
|
||||
@Test
|
||||
void authenticatesActiveUserAndReturnsStructuredSessionData() {
|
||||
IdentityUser user = user("maddy", true);
|
||||
MenuItem menuItem = new MenuItem(
|
||||
(short) 10, "Operations", "/ver/operations", (short) 0,
|
||||
(short) 1, "110", "_parent", null);
|
||||
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
|
||||
when(passwordVerifier.matches("secret", "legacy-value")).thenReturn(true);
|
||||
when(repository.findMenu((short) 4, (short) 25))
|
||||
.thenReturn(Mono.just(List.of(menuItem)));
|
||||
when(repository.recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25))
|
||||
.thenReturn(Mono.just(101L));
|
||||
|
||||
IdentityAuthenticationService service = new IdentityAuthenticationService(
|
||||
repository,
|
||||
passwordVerifier,
|
||||
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
|
||||
|
||||
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
|
||||
.assertNext(result -> {
|
||||
assertThat(result.loginId()).isEqualTo("maddy");
|
||||
assertThat(result.companyName()).isEqualTo("Matrix");
|
||||
assertThat(result.menu()).containsExactly(menuItem);
|
||||
assertThat(result.loginTime()).isEqualTo(LOGIN_TIME);
|
||||
})
|
||||
.verifyComplete();
|
||||
|
||||
verify(repository).recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInactiveUserWithoutLoadingMenu() {
|
||||
IdentityUser user = user("maddy", false);
|
||||
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
|
||||
|
||||
IdentityAuthenticationService service = new IdentityAuthenticationService(
|
||||
repository,
|
||||
passwordVerifier,
|
||||
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
|
||||
|
||||
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
|
||||
.expectError(AuthenticationException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
private IdentityUser user(String loginId, boolean active) {
|
||||
return new IdentityUser(
|
||||
(short) 25,
|
||||
loginId,
|
||||
"Maddy",
|
||||
"legacy-value",
|
||||
(short) 4,
|
||||
"Administrator",
|
||||
(short) 2,
|
||||
"Delhi",
|
||||
"DEL",
|
||||
"Delhi",
|
||||
(short) 1,
|
||||
"Matrix",
|
||||
"MCR",
|
||||
active);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cygnus.cloud.identity.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LegacyPasswordVerifierTest {
|
||||
|
||||
private final LegacyPasswordVerifier verifier = new LegacyPasswordVerifier();
|
||||
|
||||
@Test
|
||||
void matchesLegacyAesPassword() {
|
||||
assertThat(verifier.matches("password", "sS3vFSMkpzsHrGYlS1Nn6Q==")).isTrue();
|
||||
assertThat(verifier.matches("wrong", "sS3vFSMkpzsHrGYlS1Nn6Q==")).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
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.jose.crypto.RSASSAVerifier;
|
||||
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.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
|
||||
class MachineTokenFlowTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-07-23T06:30:00Z");
|
||||
private static final String CLIENT_ID = "customer-a";
|
||||
private static final String INSTALLATION_ID = "site-01";
|
||||
private static final String TOKEN_AUDIENCE =
|
||||
"https://cloud.example.test/oauth2/token";
|
||||
|
||||
@TempDir
|
||||
Path tempDirectory;
|
||||
|
||||
private KeyPair assertionEncryptionKeys;
|
||||
private KeyPair clientSigningKeys;
|
||||
private KeyPair accessTokenKeys;
|
||||
private CommunicationSecurityProperties properties;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
assertionEncryptionKeys = keyPair();
|
||||
clientSigningKeys = keyPair();
|
||||
accessTokenKeys = keyPair();
|
||||
|
||||
properties = new CommunicationSecurityProperties(
|
||||
true,
|
||||
"https://cloud.example.test",
|
||||
"cygnus-cloud-api",
|
||||
TOKEN_AUDIENCE,
|
||||
Duration.ofDays(370),
|
||||
Duration.ofMinutes(20),
|
||||
privatePem("assertion-private.pem", assertionEncryptionKeys),
|
||||
privatePem("access-private.pem", accessTokenKeys),
|
||||
publicPem("access-public.pem", accessTokenKeys),
|
||||
Map.of(
|
||||
CLIENT_ID,
|
||||
new CommunicationSecurityProperties.MachineClient(
|
||||
true,
|
||||
INSTALLATION_ID,
|
||||
publicPem("client-public.pem", clientSigningKeys),
|
||||
Set.of("identity.login"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesNestedAssertionAndIssuesBoundShortLivedAccessToken()
|
||||
throws Exception {
|
||||
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
ClientAssertionValidator validator =
|
||||
new ClientAssertionValidator(properties, clock);
|
||||
AccessTokenIssuer issuer = new AccessTokenIssuer(properties, clock);
|
||||
MachineTokenController controller =
|
||||
new MachineTokenController(validator, issuer);
|
||||
|
||||
LinkedMultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||
form.add("grant_type", "client_credentials");
|
||||
form.add("client_id", CLIENT_ID);
|
||||
form.add(
|
||||
"client_assertion_type",
|
||||
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
||||
form.add("client_assertion", encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365))));
|
||||
form.add("scope", "identity.login");
|
||||
|
||||
Map<String, Object> response = controller.issueToken(form);
|
||||
SignedJWT token = SignedJWT.parse((String) response.get("access_token"));
|
||||
|
||||
assertThat(token.verify(new RSASSAVerifier(
|
||||
(RSAPublicKey) accessTokenKeys.getPublic())))
|
||||
.isTrue();
|
||||
assertThat(token.getJWTClaimsSet().getStringClaim("client_id"))
|
||||
.isEqualTo(CLIENT_ID);
|
||||
assertThat(token.getJWTClaimsSet().getStringClaim("installation_id"))
|
||||
.isEqualTo(INSTALLATION_ID);
|
||||
assertThat(token.getJWTClaimsSet().getStringClaim("scope"))
|
||||
.isEqualTo("identity.login");
|
||||
assertThat(response)
|
||||
.containsEntry("token_type", "Bearer")
|
||||
.containsEntry("expires_in", 1200L)
|
||||
.containsEntry("scope", "identity.login");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExpiredAssertion() throws Exception {
|
||||
ClientAssertionValidator validator = new ClientAssertionValidator(
|
||||
properties, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(
|
||||
CLIENT_ID,
|
||||
encryptedAssertion(
|
||||
NOW.minus(Duration.ofDays(366)),
|
||||
NOW.minusSeconds(1))))
|
||||
.isInstanceOf(MachineAuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsOAuthFormEncodedTokenRequestOverHttp() throws Exception {
|
||||
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
MachineTokenController controller = new MachineTokenController(
|
||||
new ClientAssertionValidator(properties, clock),
|
||||
new AccessTokenIssuer(properties, clock));
|
||||
LinkedMultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||
form.add("grant_type", "client_credentials");
|
||||
form.add("client_id", CLIENT_ID);
|
||||
form.add(
|
||||
"client_assertion_type",
|
||||
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
||||
form.add(
|
||||
"client_assertion",
|
||||
encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365))));
|
||||
form.add("scope", "identity.login");
|
||||
|
||||
WebTestClient.bindToController(controller)
|
||||
.build()
|
||||
.post()
|
||||
.uri("/oauth2/token")
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(BodyInserters.fromFormData(form))
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
|
||||
.expectBody()
|
||||
.jsonPath("$.access_token").isNotEmpty()
|
||||
.jsonPath("$.token_type").isEqualTo("Bearer")
|
||||
.jsonPath("$.expires_in").isEqualTo(1200)
|
||||
.jsonPath("$.scope").isEqualTo("identity.login");
|
||||
}
|
||||
|
||||
private String encryptedAssertion(Instant issuedAt, Instant expiresAt)
|
||||
throws Exception {
|
||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||
.issuer(CLIENT_ID)
|
||||
.subject(CLIENT_ID)
|
||||
.audience(TOKEN_AUDIENCE)
|
||||
.issueTime(Date.from(issuedAt))
|
||||
.expirationTime(Date.from(expiresAt))
|
||||
.claim("installation_id", INSTALLATION_ID)
|
||||
.build();
|
||||
SignedJWT signed = new SignedJWT(
|
||||
new JWSHeader(JWSAlgorithm.RS256), claims);
|
||||
signed.sign(new RSASSASigner(
|
||||
(RSAPrivateKey) clientSigningKeys.getPrivate()));
|
||||
|
||||
JWEObject encrypted = new JWEObject(
|
||||
new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM),
|
||||
new Payload(signed.serialize()));
|
||||
encrypted.encrypt(new RSAEncrypter(
|
||||
(RSAPublicKey) assertionEncryptionKeys.getPublic()));
|
||||
return encrypted.serialize();
|
||||
}
|
||||
|
||||
private KeyPair keyPair() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return generator.generateKeyPair();
|
||||
}
|
||||
|
||||
private String privatePem(String name, KeyPair pair) throws Exception {
|
||||
return writePem(
|
||||
name,
|
||||
"PRIVATE KEY",
|
||||
pair.getPrivate().getEncoded());
|
||||
}
|
||||
|
||||
private String publicPem(String name, KeyPair pair) throws Exception {
|
||||
return writePem(
|
||||
name,
|
||||
"PUBLIC KEY",
|
||||
pair.getPublic().getEncoded());
|
||||
}
|
||||
|
||||
private String writePem(String name, String type, byte[] key) throws Exception {
|
||||
String body = Base64.getMimeEncoder(64, new byte[] {'\n'})
|
||||
.encodeToString(key);
|
||||
Path path = tempDirectory.resolve(name);
|
||||
Files.writeString(
|
||||
path,
|
||||
"-----BEGIN " + type + "-----\n"
|
||||
+ body
|
||||
+ "\n-----END " + type + "-----\n",
|
||||
StandardCharsets.US_ASCII);
|
||||
return "file:" + path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.cygnus.cloud.system;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webflux.test.autoconfigure.WebFluxTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
|
||||
@WebFluxTest(SystemInfoController.class)
|
||||
@Import(SystemInfoControllerTest.FixedClockConfiguration.class)
|
||||
class SystemInfoControllerTest {
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@Test
|
||||
void exposesVersionedServiceInformation() {
|
||||
webTestClient.get()
|
||||
.uri("/api/v1/system/info")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().contentTypeCompatibleWith("application/json")
|
||||
.expectBody()
|
||||
.jsonPath("$.service").isEqualTo("cygnus-cloud-service")
|
||||
.jsonPath("$.version").isEqualTo("1.0.0-SNAPSHOT")
|
||||
.jsonPath("$.timestamp").isEqualTo("2026-07-22T12:00:00Z");
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class FixedClockConfiguration {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
Clock fixedClock() {
|
||||
return Clock.fixed(Instant.parse("2026-07-22T12:00:00Z"), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
cygnus-cloud-service/target/classes/application.yml
Normal file
64
cygnus-cloud-service/target/classes/application.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
spring:
|
||||
application:
|
||||
name: cygnus-cloud-service
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:192.168.0.111}
|
||||
port: ${REDIS_PORT:7901}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
connect-timeout: ${REDIS_CONNECT_TIMEOUT:3s}
|
||||
timeout: ${REDIS_COMMAND_TIMEOUT:3s}
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: ${REDIS_MAX_ACTIVE:20}
|
||||
max-idle: ${REDIS_MAX_IDLE:10}
|
||||
min-idle: ${REDIS_MIN_IDLE:1}
|
||||
max-wait: ${REDIS_MAX_WAIT:2s}
|
||||
|
||||
cygnus:
|
||||
database:
|
||||
host: ${DB_HOST:192.168.0.111}
|
||||
port: ${DB_PORT:5432}
|
||||
database: ${DB_NAME:matrix}
|
||||
username: ${DB_USER:postgres}
|
||||
password: ${DB_PASSWORD:}
|
||||
ssl: ${DB_SSL:false}
|
||||
connect-timeout: ${DB_CONNECT_TIMEOUT:3s}
|
||||
pool-size: ${DB_POOL_SIZE:20}
|
||||
pool-wait-queue-size: ${DB_POOL_WAIT_QUEUE_SIZE:100}
|
||||
security:
|
||||
enabled: ${CYGNUS_SECURITY_ENABLED:false}
|
||||
issuer-uri: ${CYGNUS_JWT_ISSUER_URI:http://localhost:8090}
|
||||
audience: ${CYGNUS_JWT_AUDIENCE:cygnus-cloud-api}
|
||||
token-audience: ${CYGNUS_TOKEN_AUDIENCE:http://localhost:8090/oauth2/token}
|
||||
assertion-ttl: ${CYGNUS_ASSERTION_TTL:370d}
|
||||
access-token-ttl: ${CYGNUS_ACCESS_TOKEN_TTL:20m}
|
||||
assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem}
|
||||
access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem}
|
||||
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
|
||||
clients: {}
|
||||
login-encryption:
|
||||
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
|
||||
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
|
||||
payload-ttl: ${CYGNUS_LOGIN_PAYLOAD_TTL:5m}
|
||||
cache:
|
||||
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
|
||||
default-ttl: ${CYGNUS_CACHE_TTL:10m}
|
||||
|
||||
server:
|
||||
port: ${CYGNUS_CLOUD_PORT:8090}
|
||||
shutdown: graceful
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
|
||||
info:
|
||||
app:
|
||||
name: ${spring.application.name}
|
||||
version: 1.0.0-SNAPSHOT
|
||||
Binary file not shown.
BIN
cygnus-cloud-service/target/classes/com/cygnus/cloud/cache/CacheProperties.class
vendored
Normal file
BIN
cygnus-cloud-service/target/classes/com/cygnus/cloud/cache/CacheProperties.class
vendored
Normal file
Binary file not shown.
BIN
cygnus-cloud-service/target/classes/com/cygnus/cloud/cache/ReactiveCacheService.class
vendored
Normal file
BIN
cygnus-cloud-service/target/classes/com/cygnus/cloud/cache/ReactiveCacheService.class
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user