Files
admin/Azaion.AdminApi/Program.cs
T
Oleksandr Bezdieniezhnykh 1e1ded73f5
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
[AZ-534] TOTP-based 2FA at credential login
Add RFC 6238 TOTP enrollment, two-step /login flow, recovery codes, and
the amr=["pwd","mfa"] claim that propagates through refresh-token rotation.

- New endpoints: /users/me/mfa/{enroll,confirm,disable} and /login/mfa.
- /login short-circuits to a 5-min ES256 step-1 token (audience-pinned
  azaion-mfa-step2) when the user has MFA enabled; real access+refresh
  pair is minted only after /login/mfa.
- mfa_secret encrypted at rest via ASP.NET Core IDataProtector
  (purpose=Azaion.Mfa.Secret.v1; key folder configurable via
  DataProtection:KeysFolder for production persistence).
- Recovery codes (10 single-use, base32, ~80-bit entropy) hashed with
  SHA-256 and stored as JSONB; constant-time compare on lookup.
- RFC 6238 §5.2 replay defense via mfa_last_used_window per user.
- Sessions carry mfa_authenticated so /token/refresh re-stamps the
  amr claim correctly across the entire 30-day refresh window.
- New audit events: enroll, confirm, disable, login-success/failed,
  recovery-used.
- Schema: env/db/10_users_mfa.sql adds users.mfa_* columns and
  sessions.mfa_authenticated; mfa_recovery_codes mapped as BinaryJson
  in AzaionDbSchemaHolder; disable path uses raw parameterised SQL to
  avoid LinqToDB null-literal type-inference on jsonb columns.

E2E: 6 new tests in MfaLoginTests cover all six AC; full suite
82 passed / 0 failed / 3 intentional skips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 06:21:28 +03:00

652 lines
27 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.DataProtection;
using Azaion.Common;
using Azaion.Common.Configs;
using Azaion.Common.Database;
using Azaion.Common.Entities;
using Azaion.Common.Requests;
using Azaion.Services;
using FluentValidation;
using LinqToDB.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Serilog;
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File(
path: "logs/log.txt",
rollingInterval: RollingInterval.Day)
.CreateLogger();
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 209715200);
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
o.MultipartBodyLengthLimit = 209715200);
var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get<JwtConfig>();
if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Issuer) || string.IsNullOrEmpty(jwtConfig.Audience))
throw new Exception("Missing configuration section: JwtConfig (Issuer + Audience required)");
// AZ-532 — load ES256 signing keys eagerly so JwtBearer can resolve issuer signing
// keys via the same provider DI registers below for AuthService.
var signingKeyLoggerFactory = LoggerFactory.Create(c => c.AddSerilog(Log.Logger));
var jwtSigningKeyProvider = new JwtSigningKeyProvider(
Options.Create(jwtConfig),
signingKeyLoggerFactory.CreateLogger<JwtSigningKeyProvider>());
// Fail-fast for DB connection strings — surfaces a missing env var at startup
// instead of on the first request to a DB-backed endpoint.
var connectionStrings = builder.Configuration.GetSection(nameof(ConnectionStrings)).Get<ConnectionStrings>();
if (connectionStrings == null
|| string.IsNullOrEmpty(connectionStrings.AzaionDb)
|| string.IsNullOrEmpty(connectionStrings.AzaionDbAdmin))
throw new Exception("Missing configuration section: ConnectionStrings (AzaionDb and AzaionDbAdmin are required)");
// Graceful shutdown: 30 s for in-flight requests; pair with `docker stop -t 40`.
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(30));
builder.Services.AddSerilog();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtConfig.Issuer,
ValidAudience = jwtConfig.Audience,
// AZ-532 AC-5 — pin algorithms so a token forged with alg=HS256 using the
// public key as the HMAC secret cannot pass validation.
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256],
IssuerSigningKeyResolver = (_, _, kid, _) =>
{
if (string.IsNullOrEmpty(kid))
return jwtSigningKeyProvider.All.Select(k => (SecurityKey)k.SecurityKey);
var hit = jwtSigningKeyProvider.All.FirstOrDefault(k => k.Kid == kid);
return hit != null ? [hit.SecurityKey] : [];
}
};
});
#region Policies
var apiAdminPolicy = new AuthorizationPolicyBuilder()
.RequireRole(RoleEnum.ApiAdmin.ToString()).Build();
// AZ-535 — verifiers (satellite-provider, gps-denied, ui) authenticate as
// service-role identities and are the only callers (besides ApiAdmin) allowed
// to read the global revocation snapshot.
var revocationReaderPolicy = new AuthorizationPolicyBuilder()
.RequireRole(RoleEnum.Service.ToString(), RoleEnum.ApiAdmin.ToString()).Build();
builder.Services.AddAuthorization(o =>
{
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
o.AddPolicy(nameof(revocationReaderPolicy), revocationReaderPolicy);
});
#endregion Policies
builder.Services.AddHttpContextAccessor();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo {Title = "Azaion.API", Version = "v1"});
c.CustomSchemaIds(type => type.ToString());
var jwtSecurityScheme = new OpenApiSecurityScheme
{
Scheme = "bearer",
BearerFormat = "JWT",
Name = "JWT Authentication",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Description = "Put **_ONLY_** your JWT Bearer token on textbox below!",
};
c.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, jwtSecurityScheme);
c.AddSecurityRequirement(_ => new OpenApiSecurityRequirement
{
{ new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme, null), new List<string>() }
});
});
builder.Services.Configure<ResourcesConfig>(builder.Configuration.GetSection(nameof(ResourcesConfig)));
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection(nameof(JwtConfig)));
builder.Services.Configure<ConnectionStrings>(builder.Configuration.GetSection(nameof(ConnectionStrings)));
builder.Services.Configure<AuthConfig>(builder.Configuration.GetSection(nameof(AuthConfig)));
builder.Services.Configure<SessionConfig>(builder.Configuration.GetSection(nameof(SessionConfig)));
var authConfig = builder.Configuration.GetSection(nameof(AuthConfig)).Get<AuthConfig>() ?? new AuthConfig();
// AZ-532 — share the eagerly-built provider so JwtBearer and AuthService both
// hold the same set of loaded keys.
builder.Services.AddSingleton<IJwtSigningKeyProvider>(jwtSigningKeyProvider);
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
builder.Services.AddScoped<ISessionService, SessionService>();
builder.Services.AddScoped<IMissionTokenService, MissionTokenService>();
builder.Services.AddScoped<IMfaService, MfaService>();
// AZ-534 — DataProtection encrypts mfa_secret at rest. Default key storage
// (per-machine, ephemeral inside containers) is fine for a single-instance SUT.
// Production deployments MUST set DataProtection__KeysFolder to a persistent
// volume so encrypted secrets survive restarts and rolling deploys.
{
var dpBuilder = builder.Services.AddDataProtection();
dpBuilder.SetApplicationName("Azaion.AdminApi");
var keyFolder = builder.Configuration["DataProtection:KeysFolder"];
if (!string.IsNullOrWhiteSpace(keyFolder))
{
Directory.CreateDirectory(keyFolder);
dpBuilder.PersistKeysToFileSystem(new DirectoryInfo(keyFolder));
}
}
builder.Services.AddScoped<IResourcesService, ResourcesService>();
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
builder.Services.AddScoped<IAuditLog, AuditLog>();
builder.Services.AddSingleton<IDbFactory, DbFactory>();
builder.Services.AddLazyCache();
builder.Services.AddScoped<ICache, MemoryCache>();
builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserValidator>();
builder.Services.AddExceptionHandler<BusinessExceptionHandler>();
// AZ-537 — per-IP sliding window rate limit on /login. Per-account rate limit and
// account lockout live in UserService.ValidateUser (DB-backed) so they survive
// process restarts and feed the audit_events table.
const string LoginPerIpPolicy = "login-per-ip";
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = (ctx, _) =>
{
if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
ctx.HttpContext.Response.Headers.RetryAfter =
((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
return ValueTask.CompletedTask;
};
options.AddPolicy(LoginPerIpPolicy, httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetSlidingWindowLimiter(ip, _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = authConfig.RateLimit.PerIpPermitLimit,
Window = TimeSpan.FromSeconds(authConfig.RateLimit.PerIpWindowSeconds),
SegmentsPerWindow = 6,
QueueLimit = 0,
AutoReplenishment = true
});
});
});
// AZ-538 — only the HTTPS origin is allowed; the legacy http:// origin combined with
// AllowCredentials() permitted credentialed cleartext traffic and is now removed.
builder.Services.AddCors(options =>
{
options.AddPolicy("AdminCorsPolicy", policy =>
{
policy.WithOrigins("https://admin.azaion.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
// AZ-538 — HSTS: 1 year, includeSubDomains, preload eligible. Only attached in
// non-Development envs; Development skips both HSTS and HTTPS redirection so
// `dotnet watch` on http://localhost keeps working.
if (!builder.Environment.IsDevelopment())
{
builder.Services.AddHsts(o =>
{
o.MaxAge = TimeSpan.FromDays(365);
o.IncludeSubDomains = true;
o.Preload = true;
});
}
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
// AZ-538 — defence in depth: even if the http origin is re-added by accident
// the protocol-layer redirect kicks in first.
app.UseHsts();
app.UseHttpsRedirection();
}
app.UseCors("AdminCorsPolicy");
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseRewriter(new RewriteOptions().AddRedirect("^$", "/swagger"));
#region Health endpoints
// Anonymous; expected to be exposed only on the management interface (not via the
// public Nginx vhost). Surface contract documented in
// _docs/04_deploy/deployment_procedures.md §2 and observability.md §7.
app.MapGet("/health/live", (HttpContext http) =>
{
http.Response.Headers.CacheControl = "no-store";
return Results.Ok(new { status = "live" });
}).AllowAnonymous().ExcludeFromDescription();
app.MapGet("/health/ready", async (IDbFactory dbFactory, HttpContext http, CancellationToken ct) =>
{
http.Response.Headers.CacheControl = "no-store";
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(2));
try
{
await dbFactory.Run(db => db.ExecuteAsync<int>("SELECT 1"));
await dbFactory.RunAdmin(db => db.ExecuteAsync<int>("SELECT 1"));
return Results.Ok(new { status = "ready" });
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
return Results.Json(new { status = "not-ready", reason = "db-timeout" }, statusCode: 503);
}
catch (Exception ex)
{
return Results.Json(new { status = "not-ready", reason = ex.GetType().Name }, statusCode: 503);
}
}).AllowAnonymous().ExcludeFromDescription();
#endregion Health endpoints
app.MapPost("/login",
async (LoginRequest request,
IUserService userService,
IAuthService authService,
IRefreshTokenService refreshTokens,
ISessionService sessionService,
IMfaService mfaService,
CancellationToken cancellationToken) =>
{
var user = await userService.ValidateUser(request, ct: cancellationToken);
// AZ-534 AC-3 — MFA-enabled users get short-circuited to a step-1 token; the
// real access+refresh pair is minted only after /login/mfa.
if (user.MfaEnabled)
{
return Results.Ok(new MfaRequiredResponse
{
MfaRequired = true,
MfaToken = mfaService.IssueMfaStepToken(user.Id),
ExpiresIn = 300,
});
}
return await IssueDualTokens(user, authService, refreshTokens, sessionService, amr: null, cancellationToken);
})
.RequireRateLimiting(LoginPerIpPolicy)
.WithSummary("Login (returns access + refresh token, OR mfa_required if MFA is enabled)");
// AZ-534 AC-3 / AC-4 — second factor at credential login. Anonymous because the
// step-1 mfa_token is itself the proof the caller is mid-flow.
app.MapPost("/login/mfa",
async (MfaLoginRequest request,
IMfaService mfaService,
IUserService userService,
IAuthService authService,
IRefreshTokenService refreshTokens,
ISessionService sessionService,
CancellationToken cancellationToken) =>
{
var userId = mfaService.ValidateMfaStepToken(request.MfaToken);
var user = await userService.GetById(userId, cancellationToken)
?? throw new BusinessException(ExceptionEnum.NoEmailFound);
var amr = await mfaService.VerifyForLogin(userId, request.Code, cancellationToken);
return await IssueDualTokens(user, authService, refreshTokens, sessionService, amr, cancellationToken);
})
.AllowAnonymous()
.RequireRateLimiting(LoginPerIpPolicy)
.WithSummary("AZ-534 — second-factor verification; returns access + refresh token");
static async Task<IResult> IssueDualTokens(
User user,
IAuthService authService,
IRefreshTokenService refreshTokens,
ISessionService sessionService,
string[]? amr,
CancellationToken ct)
{
// AZ-534 — pin AMR strength to the session so refresh rotation inherits it.
var mfaAuthenticated = amr != null && amr.Contains("mfa");
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, mfaAuthenticated, ct);
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid(), amr: amr);
// AZ-533 AC-4 — post-flight reconnect: if the just-authenticated user is an
// aircraft (CompanionPC), kill any open mission session bound to it.
if (user.Role == RoleEnum.CompanionPC)
await sessionService.RevokeMissionsForAircraft(user.Id, ct);
return Results.Ok(new LoginResponse
{
AccessToken = access.Jwt,
AccessExp = access.ExpiresAt,
RefreshToken = refreshToken,
RefreshExp = session.ExpiresAt,
});
}
// AZ-531 — refresh-token rotation. Anonymous: clients pass the opaque refresh
// in the request body so an expired access token doesn't block the refresh.
app.MapPost("/token/refresh",
async (RefreshTokenRequest request,
IRefreshTokenService refreshTokens,
IUserService userService,
IAuthService authService,
ISessionService sessionService,
CancellationToken cancellationToken) =>
{
var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken);
var user = await userService.GetById(session.UserId, cancellationToken);
if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
// AZ-534 — preserve the original AMR strength across rotations.
var amr = session.MfaAuthenticated ? new[] { "pwd", "mfa" } : new[] { "pwd" };
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid(), amr: amr);
// AZ-533 AC-4 — same auto-revoke trigger as /login.
if (user.Role == RoleEnum.CompanionPC)
await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken);
return Results.Ok(new LoginResponse
{
AccessToken = access.Jwt,
AccessExp = access.ExpiresAt,
RefreshToken = newRefresh,
RefreshExp = session.ExpiresAt,
});
})
.AllowAnonymous()
.WithSummary("Rotate a refresh token; returns a fresh access + refresh pair");
// AZ-535 — logout: revoke the caller's current session (the sid claim on their
// access token). Idempotent.
app.MapPost("/logout",
async (HttpContext http, ISessionService sessions, CancellationToken ct) =>
{
var sid = ParseSidClaim(http.User);
var caller = ParseUserIdClaim(http.User);
var alreadyRevoked = await sessions.RevokeBySid(sid, caller, SessionRevokedReasons.LoggedOut, ct);
return Results.Ok(new { alreadyRevoked });
})
.RequireAuthorization()
.WithSummary("AZ-535 — revoke the caller's current session");
// AZ-535 AC-2 — sign out everywhere: revoke every active session for the caller.
app.MapPost("/logout/all",
async (HttpContext http, ISessionService sessions, CancellationToken ct) =>
{
var caller = ParseUserIdClaim(http.User);
var revoked = await sessions.RevokeAllForUser(caller, caller, SessionRevokedReasons.LoggedOutAll, ct);
return Results.Ok(new { revoked });
})
.RequireAuthorization()
.WithSummary("AZ-535 — revoke every session for the caller's user");
// AZ-535 AC-3 — admin-only revoke-by-sid.
app.MapPost("/sessions/{sid:guid}/revoke",
async (Guid sid, HttpContext http, ISessionService sessions, CancellationToken ct) =>
{
var admin = ParseUserIdClaim(http.User);
var alreadyRevoked = await sessions.RevokeBySid(sid, admin, SessionRevokedReasons.AdminRevoked, ct);
return Results.Ok(new { alreadyRevoked });
})
.RequireAuthorization(apiAdminPolicy)
.WithSummary("AZ-535 — admin revoke-by-session-id");
// AZ-535 AC-4 — verifier-poll snapshot of revoked-but-not-yet-expired sessions.
app.MapGet("/sessions/revoked",
async (DateTime? since, HttpContext http, ISessionService sessions, CancellationToken ct) =>
{
// Cap "since" to the longest plausible token TTL (12 h, matches mission cap)
// so a buggy verifier asking for "everything since 1970" doesn't cost us a
// multi-million-row table scan.
var floor = DateTime.UtcNow.AddHours(-12);
var effective = since.HasValue && since.Value > floor ? since.Value : floor;
var rows = await sessions.GetRevokedSince(effective, ct);
http.Response.Headers.CacheControl = "no-cache";
return Results.Ok(rows.Select(r => new
{
sid = r.Sid,
exp = r.Exp,
revokedAt = r.RevokedAt,
reason = r.Reason
}));
})
.RequireAuthorization(revocationReaderPolicy)
.WithSummary("AZ-535 — verifier snapshot of revoked sessions still within their TTL");
// AZ-534 — TOTP MFA enrollment + management.
app.MapPost("/users/me/mfa/enroll",
async (MfaEnrollRequest request, HttpContext http, IMfaService mfa, CancellationToken ct) =>
{
var userId = ParseUserIdClaim(http.User);
var resp = await mfa.Enroll(userId, request.Password, ct);
return Results.Ok(resp);
})
.RequireAuthorization()
.WithSummary("AZ-534 — start TOTP enrollment (pre-confirm)");
app.MapPost("/users/me/mfa/confirm",
async (MfaConfirmRequest request, HttpContext http, IMfaService mfa, CancellationToken ct) =>
{
var userId = ParseUserIdClaim(http.User);
await mfa.Confirm(userId, request.Code, ct);
return Results.Ok(new { mfaEnabled = true });
})
.RequireAuthorization()
.WithSummary("AZ-534 — confirm TOTP enrollment with a valid code");
app.MapPost("/users/me/mfa/disable",
async (MfaDisableRequest request, HttpContext http, IMfaService mfa, CancellationToken ct) =>
{
var userId = ParseUserIdClaim(http.User);
await mfa.Disable(userId, request.Password, request.Code, ct);
return Results.Ok(new { mfaEnabled = false });
})
.RequireAuthorization()
.WithSummary("AZ-534 — disable MFA (requires password + valid code)");
// AZ-533 — mission token issuance for offline UAV ops. Pilot calls with their
// interactive access token; admin returns a long-lived no-refresh token bound
// to one aircraft + one mission.
app.MapPost("/sessions/mission",
async (MissionSessionRequest request, HttpContext http, IMissionTokenService missions, CancellationToken ct) =>
{
var pilot = ParseUserIdClaim(http.User);
// TODO (AZ-534): require amr=["pwd","mfa"]; until MFA ships this is a code
// comment per the AZ-533 spec, not an enforced gate.
var resp = await missions.Issue(pilot, request, ct);
return Results.Ok(resp);
})
.RequireAuthorization()
.WithSummary("AZ-533 — issue a long-lived mission token for one UAV flight");
static Guid ParseSidClaim(System.Security.Claims.ClaimsPrincipal user) =>
Guid.TryParse(user.FindFirst(JwtRegisteredClaimNames.Sid)?.Value, out var s)
? s
: throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
static Guid ParseUserIdClaim(System.Security.Claims.ClaimsPrincipal user) =>
Guid.TryParse(user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var u)
? u
: throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
// AZ-532 — JWKS endpoint. Verifiers cache for 1 h (Cache-Control: public, max-age=3600).
app.MapGet("/.well-known/jwks.json",
(IJwtSigningKeyProvider keys, HttpContext http) =>
{
http.Response.Headers.CacheControl = "public, max-age=3600";
var jwks = new
{
keys = keys.All.Select(k =>
{
var p = k.Ecdsa.ExportParameters(includePrivateParameters: false);
return new
{
kty = "EC",
crv = "P-256",
kid = k.Kid,
use = "sig",
alg = "ES256",
x = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(p.Q.X!),
y = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(p.Q.Y!)
};
}).ToArray()
};
return Results.Json(jwks);
})
.AllowAnonymous()
.ExcludeFromDescription()
.WithSummary("JWKS — public verification keys");
app.MapPost("/users",
async (RegisterUserRequest registerUserRequest, IValidator<RegisterUserRequest> validator,
IUserService userService, CancellationToken cancellationToken) =>
{
var validation = await validator.ValidateAsync(registerUserRequest, cancellationToken);
if (!validation.IsValid)
return Results.ValidationProblem(validation.ToDictionary());
await userService.RegisterUser(registerUserRequest, cancellationToken);
return Results.Ok();
})
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Creates a new user");
app.MapPost("/devices",
async (IUserService userService, CancellationToken cancellationToken)
=> await userService.RegisterDevice(cancellationToken))
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Creates a new device (server-assigned serial, email and password)");
app.MapGet("/users/current",
async (IAuthService authService) => await authService.GetCurrentUser())
.RequireAuthorization()
.WithSummary("Get Current User");
app.MapGet("/users",
async (string? searchEmail, RoleEnum? searchRole, IUserService userService, CancellationToken ct)
=> await userService.GetUsers(searchEmail, searchRole, ct))
.RequireAuthorization(apiAdminPolicy)
.WithSummary("List users by criteria");
app.MapPut("/users/queue-offsets/set",
async ([FromBody]SetUserQueueOffsetsRequest request, IUserService userService, CancellationToken ct)
=> await userService.UpdateQueueOffsets(request.Email, request.Offsets, ct))
.RequireAuthorization()
.WithSummary("Sets user's queue offsets");
app.MapPut("/users/{email}/set-role/{role}", async (string email, RoleEnum role, IUserService userService, CancellationToken ct)
=> await userService.ChangeRole(email, role, ct))
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Set user's role");
app.MapPut("/users/{email}/enable", async (string email, IUserService userService, CancellationToken ct)
=> await userService.SetEnableStatus(email, true, ct))
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Enable user");
app.MapPut("/users/{email}/disable", async (string email, IUserService userService, CancellationToken ct)
=> await userService.SetEnableStatus(email, false, ct))
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Disable user");
app.MapDelete("/users/{email}", async (string email, IUserService userService, CancellationToken ct)
=> await userService.RemoveUser(email, ct))
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Remove user");
app.MapPost("/resources/{dataFolder?}",
async ([FromRoute]string? dataFolder, IFormFile? data, IResourcesService resourceService, CancellationToken ct) =>
{
if (data is null)
throw new BusinessException(ExceptionEnum.NoFileProvided);
await resourceService.SaveResource(dataFolder, data, ct);
})
.Accepts<IFormFile>("multipart/form-data")
.RequireAuthorization()
.WithSummary("Upload resource")
.DisableAntiforgery();
app.MapGet("/resources/list/{dataFolder?}",
async ([FromRoute]string? dataFolder, string? search, IResourcesService resourcesService, CancellationToken ct)
=> await resourcesService.ListResources(dataFolder, search, ct))
.RequireAuthorization()
.WithSummary("Lists resources in folder");
app.MapPost("/resources/clear/{dataFolder?}",
([FromRoute]string? dataFolder, IResourcesService resourcesService) => resourcesService.ClearFolder(dataFolder))
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Clear folder");
app.MapPost("/classes",
async (CreateDetectionClassRequest request, IValidator<CreateDetectionClassRequest> validator,
IDetectionClassService detectionClassService, CancellationToken ct) =>
{
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
return Results.ValidationProblem(validation.ToDictionary());
var created = await detectionClassService.Create(request, ct);
return Results.Ok(created);
})
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Creates a new detection class");
app.MapPatch("/classes/{id:int}",
async (int id, UpdateDetectionClassRequest request, IValidator<UpdateDetectionClassRequest> validator,
IDetectionClassService detectionClassService, CancellationToken ct) =>
{
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
return Results.ValidationProblem(validation.ToDictionary());
var updated = await detectionClassService.Update(id, request, ct);
return updated == null ? Results.NotFound() : Results.Ok(updated);
})
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Updates an existing detection class (partial-merge accepted)");
app.MapDelete("/classes/{id:int}",
async (int id, IDetectionClassService detectionClassService, CancellationToken ct) =>
{
var ok = await detectionClassService.Delete(id, ct);
return ok ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization(apiAdminPolicy)
.WithSummary("Deletes a detection class");
app.UseExceptionHandler(_ => {});
app.Run();