Skip to content

Commit

Permalink
Add OIDC authentication to UI / API (#913)
Browse files Browse the repository at this point in the history
  • Loading branch information
Philippluca authored Jan 12, 2024
2 parents c07cff0 + 9aa82fb commit d6b1173
Show file tree
Hide file tree
Showing 88 changed files with 3,643 additions and 740 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
run: dotnet build BDMS.sln -c Release /warnaserror

- name: Start db and api's
run: docker compose up --wait minio db api-legacy api
run: docker compose up --wait minio db api-legacy api oidc-server

- working-directory: ./src/client
run: npm ci
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,9 @@ src/client/cypress/screenshots
# Dependencies
/src/client/node_modules

# Generated debug files
src/client/public/env.js

# Testing
src/client/coverage

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Updated layer management to use the .NET API.
- Update stratigraphy management to use the .NET API.
- Hide outer ring for disabled radio buttons.
- Handle Authentication with a OpenID Connect.

## v2.0.506 - 2023-12-21

Expand Down
26 changes: 26 additions & 0 deletions config/oidc-mock-clients.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[{
"ClientId": "bdms-client",
"Description": "Client for Authorization Code flow with PKCE",
"RequireClientSecret": false,
"AlwaysIncludeUserClaimsInIdToken": true,
"AllowedGrantTypes": [
"authorization_code"
],
"AllowedResponseTypes": [
"code",
"id_token"
],
"AllowAccessTokensViaBrowser": true,
"RedirectUris": [
"http://localhost:3000"
],
"AllowedScopes": [
"openid",
"profile",
"email"
],
"AccessTokenType": "JWT",
"IdentityTokenLifetime": 3600,
"AccessTokenLifetime": 3600
}
]
66 changes: 66 additions & 0 deletions config/oidc-mock-users.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
[
{
"SubjectId":"sub_admin",
"Username":"admin",
"Password":"swissforages",
"Claims": [
{
"Type": "name",
"Value": "Admin User",
"ValueType": "string"
},
{
"Type": "family_name",
"Value": "User",
"ValueType": "string"
},
{
"Type": "given_name",
"Value": "Admin",
"ValueType": "string"
},
{
"Type": "email",
"Value": "admin.user@local.dev",
"ValueType": "string"
},
{
"Type": "email_verified",
"Value": "true",
"ValueType": "boolean"
}
]
},
{
"SubjectId":"sub_editor",
"Username":"editor",
"Password":"swissforages",
"Claims": [
{
"Type": "name",
"Value": "Editor User",
"ValueType": "string"
},
{
"Type": "family_name",
"Value": "User",
"ValueType": "string"
},
{
"Type": "given_name",
"Value": "Editor",
"ValueType": "string"
},
{
"Type": "email",
"Value": "editor.user@local.dev",
"ValueType": "string"
},
{
"Type": "email_verified",
"Value": "true",
"ValueType": "boolean"
}
]
}
]
25 changes: 25 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ services:
condition: service_healthy
environment:
REACT_APP_PROXY_HOST_API: http://api:5000/
REACT_APP_CLIENT_AUTHORITY: http://localhost:4011/
REACT_APP_CLIENT_CLIENT_ID: bdms-client
WATCHPACK_POLLING: 'true'
api-legacy:
build:
Expand Down Expand Up @@ -118,5 +120,28 @@ services:
DOTNET_USE_POLLING_FILE_WATCHER: 1
CONNECTIONSTRINGS__BdmsContext: Host=db;Username=SPAWNPLOW;Password=YELLOWSPATULA;Database=bdms;CommandTimeout=300
ReverseProxy__Clusters__pythonApi__Destinations__legacyApi__Address: "http://api-legacy:8888/"
Auth__Authority: http://oidc-server
S3__ENDPOINT: http://minio:9000
S3__SECURE: 1
oidc-server:
image: soluto/oidc-server-mock
ports:
- "4011:80"
environment:
CLIENTS_CONFIGURATION_PATH: /tmp/config/clients-config.json
USERS_CONFIGURATION_PATH: /tmp/config/users-config.json
SERVER_OPTIONS_INLINE: |
{
"IssuerUri": "http://localhost:4011",
"AccessTokenJwtType": "JWT",
"Discovery": {
"ShowKeySet": true
},
"Authentication": {
"CookieSameSiteMode": "Lax",
"CheckSessionCookieSameSiteMode": "Lax"
}
}
volumes:
- ./config/oidc-mock-clients.json:/tmp/config/clients-config.json:ro
- ./config/oidc-mock-users.json:/tmp/config/users-config.json:ro
10 changes: 5 additions & 5 deletions src/api-legacy/v1/basehandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,16 @@ def __init__(self, *args, **kwargs):
async def prepare(self):

auth_header = self.request.headers.get('Authorization')

if auth_header is None:
self.set_header('WWW-Authenticate', 'Basic realm=BDMS')
self.set_status(401)
self.finish()
return

username = auth_header
subject_id = auth_header

async with self.pool.acquire() as conn:
async with self.pool.acquire() as conn:

val = await conn.fetchval("""
SELECT row_to_json(t)
Expand Down Expand Up @@ -167,11 +167,11 @@ async def prepare(self):
ON w.id_usr_fk = id_usr
WHERE
username = $1
subject_id = $1
AND
disabled_usr IS NULL
) as t
""", username)
""", subject_id)

if val is None:
self.set_status(401)
Expand Down
5 changes: 3 additions & 2 deletions src/api-legacy/v1/user/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ async def execute(self, request):
for workgroup in self.user['workgroups']:
if workgroup['disabled'] is not None:
workgroup['roles'] = ['VIEW']

workgroups.append(workgroup)

for role in workgroup['roles']:
if role not in roles:
roles.append(role)
Expand All @@ -33,6 +33,7 @@ async def execute(self, request):
"name": self.user['name'],
"roles": roles,
"terms": self.user['terms'],
"id": self.user['id'],
"username": self.user['username'],
"viewer": self.user['viewer'],
"workgroups": workgroups
Expand Down
66 changes: 0 additions & 66 deletions src/api/Authentication/BasicAuthenticationHandler.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using BDMS.Models;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;

namespace BDMS.Authentication;

public class DatabaseAuthenticationClaimsTransformation : IClaimsTransformation
{
private readonly BdmsContext dbContext;

/// <summary>
/// Initializes a new instance of the <see cref="DatabaseAuthenticationClaimsTransformation"/> class.
/// </summary>
/// <param name="dbContext">The EF database context containing data for the BDMS application.</param>
/// <exception cref="ArgumentNullException">If <paramref name="dbContext"/> is <c>null</c>.</exception>
public DatabaseAuthenticationClaimsTransformation(BdmsContext dbContext)
{
this.dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}

/// <inheritdoc/>
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var userId = principal.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier);
if (userId is null) return principal;

var authenticatedUser = dbContext.Users.FirstOrDefault(u => u.SubjectId == userId.Value)
?? new User
{
SubjectId = userId.Value,
Password = "Undefined", // TODO: Remove with #911
};
if (authenticatedUser.IsDisabled) return principal;

authenticatedUser.FirstName = principal.Claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value ?? authenticatedUser.FirstName;
authenticatedUser.LastName = principal.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value ?? authenticatedUser.LastName;
authenticatedUser.Name = $"{authenticatedUser.FirstName[0]}. {authenticatedUser.LastName}";
dbContext.Update(authenticatedUser);
await dbContext.SaveChangesAsync().ConfigureAwait(false);

var claimsIdentity = new ClaimsIdentity();
claimsIdentity.AddClaim(new Claim(ClaimTypes.Name, authenticatedUser.Name));
if (authenticatedUser.IsAdmin) claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, PolicyNames.Admin));
else if (authenticatedUser.IsViewer) claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, PolicyNames.Viewer));

principal.AddIdentity(claimsIdentity);
return principal;
}
}
8 changes: 4 additions & 4 deletions src/api/Authentication/LegacyApiAuthenticationMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (context.Request.Path.StartsWithSegments(new PathString("/api/v1"), StringComparison.OrdinalIgnoreCase))
{
var userName = context.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name);
if (userName is not null)
var subjectId = context.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier);
if (subjectId is not null)
{
context.Request.Headers.Authorization = userName.Value;
context.Request.Headers.Authorization = subjectId.Value;

await next.Invoke(context).ConfigureAwait(false);

logger.LogInformation("Authorized user <{UserName}> for legacy api accessing route <{Route}>", userName.Value, context.Request.Path);
logger.LogInformation("Authorized user with subject_id <{SubjectId}> for legacy api accessing route <{Route}>", subjectId.Value, context.Request.Path);
return;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/api/BDMS.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="8.0.0" />
<PackageReference Include="Humanizer" Version="2.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0" />
Expand Down
4 changes: 2 additions & 2 deletions src/api/BdmsContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ public BdmsContext(DbContextOptions options)
/// </summary>
public async Task<int> UpdateChangeInformationAndSaveChangesAsync(HttpContext httpContext)
{
var userName = httpContext?.User.FindFirst(ClaimTypes.Name)?.Value;
var subjectId = httpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

var entities = ChangeTracker.Entries<IChangeTracking>();
var user = await Users
.AsNoTracking()
.SingleOrDefaultAsync(u => u.Name == userName)
.SingleOrDefaultAsync(u => u.SubjectId == subjectId)
.ConfigureAwait(false);

foreach (var entity in entities)
Expand Down
6 changes: 3 additions & 3 deletions src/api/BoreholeFileUploadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ public async Task UploadFileAndLinkToBorehole(IFormFile file, int boreholeId)
using var transaction = context.Database.CurrentTransaction == null ? await context.Database.BeginTransactionAsync().ConfigureAwait(false) : null;
try
{
var userName = httpContextAccessor.HttpContext?.GetUserName();
var subjectId = httpContextAccessor.HttpContext?.GetUserSubjectId();

var user = await context.Users
.AsNoTracking()
.SingleOrDefaultAsync(u => u.Name == userName)
.SingleOrDefaultAsync(u => u.SubjectId == subjectId)
.ConfigureAwait(false);

if (user == null || userName == null) throw new InvalidOperationException($"No user with username <{userName}> found.");
if (user == null || subjectId == null) throw new InvalidOperationException($"No user with subject_id <{subjectId}> found.");

// If file does not exist on storage, upload it and create file in database.
if (fileId == null)
Expand Down
Loading

0 comments on commit d6b1173

Please sign in to comment.