diff --git a/.gitignore b/.gitignore index 6ac5cbb..a5a5e60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /postgres-keycloak-data 2024__yandex-architecture-sso.code-workspace +bin/ +obj/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..1c035b5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,40 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md. + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/API/bin/Debug/net8.0/api.dll", + "args": [], + "cwd": "${workspaceFolder}/API", + "stopAtEntry": false, + // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_HTTP_PORTS":"8000", + "APP_KEYCLOAK_URL": "http://localhost:8080", + "APP_KEYCLOAK_REALM": "reports-realm", + "APP_KEYCLOAK_CLIENT_ID": "reports-api", + "APP_KEYCLOAK_CLIENT_SECRET": "oNwoLQdvJAvRcL89SydqCWCe5ry1jMgq" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7170242 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/API/api.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/API/api.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/API/api.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/API/ClaimsTransformer.cs b/API/ClaimsTransformer.cs new file mode 100644 index 0000000..4c5dddf --- /dev/null +++ b/API/ClaimsTransformer.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authentication; +using System.Security.Claims; + +public class ClaimsTransformer : IClaimsTransformation +{ + public Task TransformAsync(ClaimsPrincipal principal) + { + ClaimsIdentity claimsIdentity = (ClaimsIdentity)principal.Identity; + + // flatten realm_access because Microsoft identity model doesn't support nested claims + // by map it to Microsoft identity model, because automatic JWT bearer token mapping already processed here + if (claimsIdentity.IsAuthenticated && claimsIdentity.HasClaim((claim) => claim.Type == "realm_access")) + { + var realmAccessClaim = claimsIdentity.FindFirst((claim) => claim.Type == "realm_access"); + var realmAccessAsDict = System.Text.Json.JsonSerializer.Deserialize>(realmAccessClaim.Value); + if (realmAccessAsDict["roles"] != null) + { + foreach (var role in realmAccessAsDict["roles"]) + { + claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, role)); + } + } + } + + return Task.FromResult(principal); + } +} diff --git a/API/Dockerfile b/API/Dockerfile new file mode 100644 index 0000000..a087e4d --- /dev/null +++ b/API/Dockerfile @@ -0,0 +1,15 @@ +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build-stage +WORKDIR /app + +COPY api.csproj api.csproj +RUN dotnet restore --verbosity minimal +COPY . . + +RUN dotnet publish api.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build-stage /app/out . + +ENTRYPOINT ["dotnet", "api.dll"] +EXPOSE 8080 diff --git a/API/Program.cs b/API/Program.cs new file mode 100644 index 0000000..cc6dbd8 --- /dev/null +++ b/API/Program.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddOpenApiDocument(config => +{ + config.DocumentName = "api"; + config.Title = "api v1"; + config.Version = "v1"; +}); +var keycloak = Environment.GetEnvironmentVariable("APP_KEYCLOAK_URL"); +var realm = Environment.GetEnvironmentVariable("APP_KEYCLOAK_REALM"); +var clientId = Environment.GetEnvironmentVariable("APP_KEYCLOAK_CLIENT_ID"); +var secret = Environment.GetEnvironmentVariable("APP_KEYCLOAK_CLIENT_SECRET"); + +builder.Services.AddTransient(); + +// https://dev.to/kayesislam/integrating-openid-connect-to-your-application-stack-25ch +builder.Services + .AddAuthentication() + .AddJwtBearer(x => + { + x.RequireHttpsMetadata = false; + x.MetadataAddress = $"{keycloak}/realms/{realm}/.well-known/openid-configuration"; + x.ClaimsIssuer = $"{keycloak}/realms/{realm}"; + x.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = false, + ValidIssuers = new[] { $"{keycloak}/realms/{realm}" }, + // IDX10500: Signature validation failed. No security keys were provided to validate the signature on K8s + SignatureValidator = delegate (string token, Microsoft.IdentityModel.Tokens.TokenValidationParameters parameters) + { + var jwt = new Microsoft.IdentityModel.JsonWebTokens.JsonWebToken(token); + return jwt; + } + + }; + }); + +builder.Services.AddAuthorization(x => x.AddPolicy("reports", y => +{ + y.RequireRole("prothetic_user"); +})); +builder.Services.AddCors(); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseCors(x => x.AllowAnyMethod().AllowAnyHeader().AllowAnyOrigin()); + + +app.MapGet("/reports", [Authorize("reports")] (HttpContext context) => +{ + return Guid.NewGuid().ToString(); +}); +app.UseOpenApi(); +app.UseSwaggerUi(config => +{ + config.DocumentTitle = "api"; + config.Path = "/swagger"; + config.DocumentPath = "/swagger/{documentName}/swagger.json"; + config.DocExpansion = "list"; +}); + + +app.Run(); diff --git a/API/api.csproj b/API/api.csproj new file mode 100644 index 0000000..32f676a --- /dev/null +++ b/API/api.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/docker-compose.yaml b/docker-compose.yaml index f21d8cf..a846d71 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: keycloak_db: @@ -20,7 +20,7 @@ services: KC_DB_URL: jdbc:postgresql://keycloak_db:5432/keycloak_db KC_DB_USERNAME: keycloak_user KC_DB_PASSWORD: keycloak_password - command: + command: - start-dev - --import-realm volumes: @@ -40,3 +40,14 @@ services: REACT_APP_KEYCLOAK_URL: http://localhost:8080 REACT_APP_KEYCLOAK_REALM: reports-realm REACT_APP_KEYCLOAK_CLIENT_ID: reports-frontend + api: + build: + context: ./API + dockerfile: Dockerfile + ports: + - "8000:8080" + environment: + APP_KEYCLOAK_URL: http://localhost:8080 + APP_KEYCLOAK_REALM: reports-realm + APP_KEYCLOAK_CLIENT_ID: reports-api + APP_KEYCLOAK_CLIENT_SECRET: oNwoLQdvJAvRcL89SydqCWCe5ry1jMgq diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c5aaaf0..e056814 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,14 +6,22 @@ import ReportPage from './components/ReportPage'; const keycloakConfig: KeycloakConfig = { url: process.env.REACT_APP_KEYCLOAK_URL, realm: process.env.REACT_APP_KEYCLOAK_REALM||"", - clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID||"" + clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID || "" }; + const keycloak = new Keycloak(keycloakConfig); + +const keycloakOptions={ + onLoad: "check-sso", + pkceMethod: "S256", + silentCheckSsoRedirectUri: + window.location.origin + "/silent-check-sso.html" +}; const App: React.FC = () => { return ( - +
diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 9646a4b..3f58ac9 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -1,129 +1,132 @@ { - "realm": "reports-realm", - "enabled": true, - "roles": { - "realm": [ - { - "name": "user", - "description": "Regular user role" - }, - { - "name": "administrator", - "description": "Administrator role" - }, - { - "name": "prothetic_user", - "description": "Prothetic user role with report access" - } - ] - }, - "users": [ - { - "username": "user1", - "enabled": true, - "email": "user1@example.com", - "firstName": "User", - "lastName": "One", - "credentials": [ - { - "type": "password", - "value": "password123", - "temporary": false - } - ], - "realmRoles": ["user"] - }, - { - "username": "user2", - "enabled": true, - "email": "user2@example.com", - "firstName": "User", - "lastName": "Two", - "credentials": [ - { - "type": "password", - "value": "password123", - "temporary": false - } - ], - "realmRoles": ["user"] - }, + "realm": "reports-realm", + "enabled": true, + "roles": { + "realm": [ { - "username": "admin1", - "enabled": true, - "email": "admin1@example.com", - "firstName": "Admin", - "lastName": "One", - "credentials": [ - { - "type": "password", - "value": "admin123", - "temporary": false - } - ], - "realmRoles": ["administrator"] + "name": "user", + "description": "Regular user role" }, { - "username": "prothetic1", - "enabled": true, - "email": "prothetic1@example.com", - "firstName": "Prothetic", - "lastName": "One", - "credentials": [ - { - "type": "password", - "value": "prothetic123", - "temporary": false - } - ], - "realmRoles": ["prothetic_user"] + "name": "administrator", + "description": "Administrator role" }, { - "username": "prothetic2", - "enabled": true, - "email": "prothetic2@example.com", - "firstName": "Prothetic", - "lastName": "Two", - "credentials": [ - { - "type": "password", - "value": "prothetic123", - "temporary": false - } - ], - "realmRoles": ["prothetic_user"] - }, - { - "username": "prothetic3", - "enabled": true, - "email": "prothetic3@example.com", - "firstName": "Prothetic", - "lastName": "Three", - "credentials": [ - { - "type": "password", - "value": "prothetic123", - "temporary": false - } - ], - "realmRoles": ["prothetic_user"] - } - ], - "clients": [ - { - "clientId": "reports-frontend", - "enabled": true, - "publicClient": true, - "redirectUris": ["http://localhost:3000/*"], - "webOrigins": ["http://localhost:3000"], - "directAccessGrantsEnabled": true - }, - { - "clientId": "reports-api", - "enabled": true, - "clientAuthenticatorType": "client-secret", - "secret": "oNwoLQdvJAvRcL89SydqCWCe5ry1jMgq", - "bearerOnly": true + "name": "prothetic_user", + "description": "Prothetic user role with report access" } ] - } \ No newline at end of file + }, + "users": [ + { + "username": "user1", + "enabled": true, + "email": "user1@example.com", + "firstName": "User", + "lastName": "One", + "credentials": [ + { + "type": "password", + "value": "password123", + "temporary": false + } + ], + "realmRoles": ["user"] + }, + { + "username": "user2", + "enabled": true, + "email": "user2@example.com", + "firstName": "User", + "lastName": "Two", + "credentials": [ + { + "type": "password", + "value": "password123", + "temporary": false + } + ], + "realmRoles": ["user"] + }, + { + "username": "admin1", + "enabled": true, + "email": "admin1@example.com", + "firstName": "Admin", + "lastName": "One", + "credentials": [ + { + "type": "password", + "value": "admin123", + "temporary": false + } + ], + "realmRoles": ["administrator"] + }, + { + "username": "prothetic1", + "enabled": true, + "email": "prothetic1@example.com", + "firstName": "Prothetic", + "lastName": "One", + "credentials": [ + { + "type": "password", + "value": "prothetic123", + "temporary": false + } + ], + "realmRoles": ["prothetic_user"] + }, + { + "username": "prothetic2", + "enabled": true, + "email": "prothetic2@example.com", + "firstName": "Prothetic", + "lastName": "Two", + "credentials": [ + { + "type": "password", + "value": "prothetic123", + "temporary": false + } + ], + "realmRoles": ["prothetic_user"] + }, + { + "username": "prothetic3", + "enabled": true, + "email": "prothetic3@example.com", + "firstName": "Prothetic", + "lastName": "Three", + "credentials": [ + { + "type": "password", + "value": "prothetic123", + "temporary": false + } + ], + "realmRoles": ["prothetic_user"] + } + ], + "clients": [ + { + "clientId": "reports-frontend", + "enabled": true, + "publicClient": true, + "redirectUris": ["http://localhost:3000/*"], + "webOrigins": ["http://localhost:3000"], + "directAccessGrantsEnabled": true, + "attributes": { + "pkce.code.challenge.method": "S256" + } + }, + { + "clientId": "reports-api", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "oNwoLQdvJAvRcL89SydqCWCe5ry1jMgq", + "bearerOnly": true + } + ] +}