Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,23 @@ on:
- 'backend/**'
- '.github/workflows/backend-ci.yml'

# A newer push to the same branch makes an in-flight run redundant.
concurrency:
group: backend-ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 20

# Exposed at job level so steps can skip Sonar when the secret is absent, which is what
# happens on a fork or before the token has been configured.
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }}

steps:
- name: Checkout code
Expand All @@ -23,17 +37,18 @@ jobs:
global-json-file: backend/global.json

- name: Setup Java (required by Sonar scanner)
if: env.SONAR_TOKEN != ''
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'

- name: Install SonarCloud scanner
if: env.SONAR_TOKEN != ''
run: dotnet tool install --global dotnet-sonarscanner

- name: Begin SonarCloud analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }}
if: env.SONAR_TOKEN != ''
run: |
dotnet sonarscanner begin \
/k:"RidentIT_RestaurantPOS" \
Expand Down Expand Up @@ -72,13 +87,14 @@ jobs:
working-directory: backend

- name: End SonarCloud analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }}
if: env.SONAR_TOKEN != ''
run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN_BACKEND }}"
working-directory: backend

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: backend/**/TestResults/**/coverage.opencover.xml
path: backend/**/TestResults/**/coverage.opencover.xml
if-no-files-found: warn
9 changes: 9 additions & 0 deletions .github/workflows/backend-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,18 @@ on:
- 'backend/**'
- '.github/workflows/backend-pr.yml'

concurrency:
group: backend-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: read

jobs:
pr-validation:
runs-on: ubuntu-latest
timeout-minutes: 20

# Expose secret as job-level env so steps can check if it is set
env:
Expand Down
18 changes: 17 additions & 1 deletion .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,20 @@ on:
- 'frontend/**'
- '.github/workflows/frontend-ci.yml'

concurrency:
group: frontend-ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 25

env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }}

steps:
- name: Checkout code
Expand Down Expand Up @@ -41,11 +52,14 @@ jobs:
working-directory: frontend

- name: Upload Coverage to Artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: frontend/coverage
if-no-files-found: warn

# Playwright serves the built output via `vite preview`, so the build must come first.
- name: Build Application (Vite)
run: npm run build
working-directory: frontend
Expand All @@ -64,11 +78,13 @@ jobs:
with:
name: playwright-report
path: frontend/playwright-report
if-no-files-found: ignore

- name: SonarCloud Main Analysis
if: env.SONAR_TOKEN != ''
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }}
with:
projectBaseDir: frontend
projectBaseDir: frontend
9 changes: 9 additions & 0 deletions .github/workflows/frontend-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,18 @@ on:
- 'frontend/**'
- '.github/workflows/frontend-pr.yml'

concurrency:
group: frontend-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: read

jobs:
pr-validation:
runs-on: ubuntu-latest
timeout-minutes: 20

env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }}
Expand Down
8 changes: 7 additions & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ StyleCopReport.xml
*.scc
logs/
*.db
*.db
*.db-shm
*.db-wal
integrationtests.db

# Chutzpah Test files
Expand Down Expand Up @@ -484,3 +485,8 @@ $RECYCLE.BIN/

# Vim temporary swap files
*.swp

# Auto-generated JWT signing key (see JwtSigningKeyProvider).
# Machine-local secret: never commit it, and never share it between installations.
keys/
*.key
3 changes: 3 additions & 0 deletions backend/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<!-- Run on a newer major runtime when the 9.0 runtime is absent. Keeps the app launchable
on developer machines and tills that only have .NET 10 installed, without retargeting. -->
<RollForward>Major</RollForward>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Expand Down
9 changes: 7 additions & 2 deletions backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,22 @@
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.18" />
<PackageVersion Include="Testcontainers" Version="3.10.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="3.10.0" />
<PackageVersion Include="NetArchTest.Rules" Version="1.3.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.18" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.18" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.18" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.18" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.18" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.18" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageVersion Include="FluentValidation" Version="11.12.0" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.12.0" />
<PackageVersion Include="MediatR" Version="12.5.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.18" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageVersion Include="Asp.Versioning.Http" Version="8.1.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
</ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion backend/global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "9.0.311",
"rollForward": "latestFeature"
"rollForward": "latestMajor"
}
}
24 changes: 24 additions & 0 deletions backend/src/RestaurantPOS.API/Contracts/Auth/AuthRequests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using RestaurantPOS.Domain.Entities;

namespace RestaurantPOS.API.Contracts.Auth;

/// <summary>Sign-in credentials.</summary>
public sealed record LoginRequest(string Username, string Password);

/// <summary>Exchanges a refresh token for a new session.</summary>
public sealed record RefreshRequest(string RefreshToken);

/// <summary>Ends a session. The token is optional so sign-out never fails.</summary>
public sealed record LogoutRequest(string? RefreshToken);

/// <summary>Changes the signed-in user's own password.</summary>
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);

/// <summary>
/// Sets the signed-in administrator's approval PIN. Leave <paramref name="Pin"/> null to have
/// the server generate a random <see cref="User.ApprovalPinLength"/>-digit PIN.
/// </summary>
public sealed record SetApprovalPinRequest(string CurrentPassword, string? Pin);

/// <summary>Presents a PIN for authorisation of a privileged action.</summary>
public sealed record VerifyApprovalPinRequest(string Pin, string? Reason);
26 changes: 26 additions & 0 deletions backend/src/RestaurantPOS.API/Contracts/Users/UserRequests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using RestaurantPOS.Domain.Enums;

namespace RestaurantPOS.API.Contracts.Users;

/// <summary>Creates a staff account.</summary>
/// <param name="Modules">Ignored for the Admin role, which holds every module.</param>
public sealed record CreateUserRequest(
string Username,
string FullName,
string? Email,
string Password,
UserRole Role,
IReadOnlyCollection<AppModule>? Modules);

/// <summary>Updates a staff account's profile, role and module grants.</summary>
public sealed record UpdateUserRequest(
string FullName,
string? Email,
UserRole Role,
IReadOnlyCollection<AppModule>? Modules);

/// <summary>Sets a temporary password that the user must then change.</summary>
public sealed record ResetUserPasswordRequest(string NewPassword);

/// <summary>Enables or disables a staff account.</summary>
public sealed record SetUserActiveRequest(bool IsActive);
123 changes: 123 additions & 0 deletions backend/src/RestaurantPOS.API/Endpoints/AuthEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using MediatR;

using RestaurantPOS.API.Contracts.Auth;
using RestaurantPOS.API.Extensions;
using RestaurantPOS.API.Security;
using RestaurantPOS.Application.Authentication.Commands.ChangePassword;
using RestaurantPOS.Application.Authentication.Commands.ClearApprovalPin;
using RestaurantPOS.Application.Authentication.Commands.Login;
using RestaurantPOS.Application.Authentication.Commands.Logout;
using RestaurantPOS.Application.Authentication.Commands.RefreshSession;
using RestaurantPOS.Application.Authentication.Commands.SetApprovalPin;
using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin;
using RestaurantPOS.Application.Authentication.Queries.GetCurrentUser;

namespace RestaurantPOS.API.Endpoints;

/// <summary>Sign-in, session lifecycle and the administrator approval PIN.</summary>
public static class AuthEndpoints
{
/// <summary>Rate-limiter policy guarding endpoints that accept a guessable secret.</summary>
public const string SensitiveRateLimitPolicy = "sensitive";

public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder routes)
{
ArgumentNullException.ThrowIfNull(routes);

var group = routes.MapGroup("/auth").WithTags("Authentication");

group.MapPost("/login", async (LoginRequest request, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new LoginCommand(request.Username, request.Password), ct);
return result.ToHttpResult();
})
.AllowAnonymous()
.RequireRateLimiting(SensitiveRateLimitPolicy)
.WithName("Login")
.WithSummary("Signs in with a username and password.");

group.MapPost("/refresh", async (RefreshRequest request, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new RefreshSessionCommand(request.RefreshToken), ct);
return result.ToHttpResult();
})
.AllowAnonymous()
.WithName("RefreshSession")
.WithSummary("Exchanges a refresh token for a new session.");

group.MapPost("/logout", async (LogoutRequest request, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new LogoutCommand(request.RefreshToken), ct);
return result.ToHttpResult();
})
.AllowAnonymous()
.WithName("Logout")
.WithSummary("Revokes a refresh token.");

group.MapGet("/me", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetCurrentUserQuery(), ct);
return result.ToHttpResult();
})
.RequireAuthorization()
// Reachable mid-reset so the client can render the user's name on that screen.
.WithMetadata(new AllowPendingPasswordChangeAttribute())
.WithName("GetCurrentUser")
.WithSummary("Returns the signed-in user and their effective module access.");

group.MapPost("/change-password",
async (ChangePasswordRequest request, ISender sender, CancellationToken ct) =>
{
var command = new ChangePasswordCommand(request.CurrentPassword, request.NewPassword);
var result = await sender.Send(command, ct);
return result.ToHttpResult();
})
.RequireAuthorization()
// The whole point of this endpoint is to clear the pending-change state.
.WithMetadata(new AllowPendingPasswordChangeAttribute())
.RequireRateLimiting(SensitiveRateLimitPolicy)
.WithName("ChangePassword")
.WithSummary("Changes the signed-in user's password and returns a fresh session.");

MapApprovalPinEndpoints(group);

return routes;
}

private static void MapApprovalPinEndpoints(RouteGroupBuilder group)
{
group.MapPost("/pin", async (SetApprovalPinRequest request, ISender sender, CancellationToken ct) =>
{
var command = new SetApprovalPinCommand(request.CurrentPassword, request.Pin);
var result = await sender.Send(command, ct);
return result.ToHttpResult();
})
.RequireAuthorization(AuthorizationPolicies.AdminOnly)
.RequireRateLimiting(SensitiveRateLimitPolicy)
.WithName("SetApprovalPin")
.WithSummary("Sets or generates the administrator's 4-digit approval PIN.");

group.MapDelete("/pin", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new ClearApprovalPinCommand(), ct);
return result.ToHttpResult();
})
.RequireAuthorization(AuthorizationPolicies.AdminOnly)
.WithName("ClearApprovalPin")
.WithSummary("Removes the administrator's approval PIN.");

group.MapPost("/pin/verify",
async (VerifyApprovalPinRequest request, ISender sender, CancellationToken ct) =>
{
var command = new VerifyApprovalPinCommand(request.Pin, request.Reason);
var result = await sender.Send(command, ct);
return result.ToHttpResult();
})
// Any signed-in user may present a PIN: the point is that a cashier calls this with
// an administrator standing over their shoulder to authorise, say, a void.
.RequireAuthorization()
.RequireRateLimiting(SensitiveRateLimitPolicy)
.WithName("VerifyApprovalPin")
.WithSummary("Authorises a privileged action with an administrator's PIN.");
}
}
Loading
Loading