Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api
diff options
context:
space:
mode:
authorLeitwolf112026-08-28 23:03:57 +0300
committerLeitwolf112026-08-28 23:03:57 +0300
commit18da54ce240b1bfefce36aa2ec3b44b58bd0dbde (patch)
treece146c2ca1d768f3f6418643b8ba3e6d8abce4df /apps/api
downloadprogmaweb-18da54ce240b1bfefce36aa2ec3b44b58bd0dbde.tar.gz
Initialize Progmaweb 1.0.0 corporate and account platform: add a responsive Next.js corporate site without Visual X# compiler-pipeline content, host-aware account routes, registration and dashboard UX, an ASP.NET Core authentication API with account policies, password hashing, secure sessions, rate limiting and security headers, integration tests on Microsoft Testing Platform, locked dependency graphs, reproducible local orchestration, and separate frontend, backend and security workflows.
Diffstat (limited to 'apps/api')
-rw-r--r--apps/api/Accounts/AccountContracts.cs24
-rw-r--r--apps/api/Accounts/AccountEndpoints.cs95
-rw-r--r--apps/api/Accounts/AccountNamePolicy.cs46
-rw-r--r--apps/api/Accounts/AccountRecord.cs17
-rw-r--r--apps/api/Accounts/AccountService.cs110
-rw-r--r--apps/api/Accounts/EmailAddressPolicy.cs39
-rw-r--r--apps/api/Accounts/IAccountStore.cs12
-rw-r--r--apps/api/Accounts/InMemoryAccountStore.cs86
-rw-r--r--apps/api/Accounts/PasswordPolicy.cs31
-rw-r--r--apps/api/Accounts/SessionService.cs89
-rw-r--r--apps/api/Progmaweb.Api.csproj8
-rw-r--r--apps/api/Program.cs64
-rw-r--r--apps/api/Security/SecurityHeadersMiddleware.cs18
-rw-r--r--apps/api/appsettings.Development.json10
-rw-r--r--apps/api/appsettings.json14
-rw-r--r--apps/api/packages.lock.json6
16 files changed, 669 insertions, 0 deletions
diff --git a/apps/api/Accounts/AccountContracts.cs b/apps/api/Accounts/AccountContracts.cs
new file mode 100644
index 0000000..d37e5cb
--- /dev/null
+++ b/apps/api/Accounts/AccountContracts.cs
@@ -0,0 +1,24 @@
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+public sealed record RegisterAccountRequest(string? AccountName, string? Email, string? Password);
+
+public sealed record LoginAccountRequest(string? Email, string? Password);
+
+public sealed record AccountResponse(string AccountName, string Email, DateTimeOffset CreatedAt);
+
+internal sealed record AuthenticatedAccount(AccountRecord Account, string SessionToken, DateTimeOffset ExpiresAt);
+
+internal enum CreateAccountStatus
+{
+ Created,
+ AccountNameUnavailable,
+ EmailUnavailable
+}
+
+internal sealed record CreateAccountResult(CreateAccountStatus Status, AccountRecord? Account)
+{
+ public static CreateAccountResult Created(AccountRecord account) => new(CreateAccountStatus.Created, account);
+ public static CreateAccountResult AccountNameUnavailable() => new(CreateAccountStatus.AccountNameUnavailable, null);
+ public static CreateAccountResult EmailUnavailable() => new(CreateAccountStatus.EmailUnavailable, null);
+}
+
diff --git a/apps/api/Accounts/AccountEndpoints.cs b/apps/api/Accounts/AccountEndpoints.cs
new file mode 100644
index 0000000..c8ddc15
--- /dev/null
+++ b/apps/api/Accounts/AccountEndpoints.cs
@@ -0,0 +1,95 @@
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+internal static class AccountEndpoints
+{
+ public static IEndpointRouteBuilder MapAccountEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ RouteGroupBuilder accounts = endpoints.MapGroup("/api/v1/accounts");
+
+ accounts.MapPost("/register", RegisterAsync).RequireRateLimiting("account-auth");
+ accounts.MapPost("/login", LoginAsync).RequireRateLimiting("account-auth");
+ accounts.MapPost("/logout", LogoutAsync);
+ accounts.MapGet("/me", MeAsync);
+
+ return endpoints;
+ }
+
+ private static async Task<IResult> RegisterAsync(
+ RegisterAccountRequest request,
+ AccountService accounts,
+ SessionService sessions,
+ HttpContext context,
+ CancellationToken cancellationToken)
+ {
+ (AuthenticatedAccount? authentication, Dictionary<string, string[]> errors) =
+ await accounts.RegisterAsync(request, sessions, cancellationToken);
+
+ if (authentication is null)
+ {
+ return Results.ValidationProblem(errors);
+ }
+
+ WriteSessionCookie(context, sessions, authentication);
+ return Results.Created($"/api/v1/accounts/{Uri.EscapeDataString(authentication.Account.AccountName)}",
+ ToResponse(authentication.Account));
+ }
+
+ private static async Task<IResult> LoginAsync(
+ LoginAccountRequest request,
+ AccountService accounts,
+ SessionService sessions,
+ HttpContext context,
+ CancellationToken cancellationToken)
+ {
+ AuthenticatedAccount? authentication = await accounts.LoginAsync(request, sessions, cancellationToken);
+ if (authentication is null)
+ {
+ return Results.Problem(
+ title: "Sign-in failed",
+ detail: "The email address or password is incorrect.",
+ statusCode: StatusCodes.Status401Unauthorized);
+ }
+
+ WriteSessionCookie(context, sessions, authentication);
+ return Results.Ok(ToResponse(authentication.Account));
+ }
+
+ private static async Task<IResult> LogoutAsync(
+ SessionService sessions,
+ HttpContext context,
+ CancellationToken cancellationToken)
+ {
+ context.Request.Cookies.TryGetValue(sessions.CookieName, out string? token);
+ await sessions.RevokeAsync(token, cancellationToken);
+ context.Response.Cookies.Delete(sessions.CookieName, new CookieOptions
+ {
+ HttpOnly = true,
+ Path = "/",
+ SameSite = SameSiteMode.Lax,
+ Secure = true
+ });
+ return Results.NoContent();
+ }
+
+ private static async Task<IResult> MeAsync(
+ SessionService sessions,
+ HttpContext context,
+ CancellationToken cancellationToken)
+ {
+ context.Request.Cookies.TryGetValue(sessions.CookieName, out string? token);
+ AccountRecord? account = await sessions.AuthenticateAsync(token, cancellationToken);
+ return account is null ? Results.Unauthorized() : Results.Ok(ToResponse(account));
+ }
+
+ private static void WriteSessionCookie(
+ HttpContext context,
+ SessionService sessions,
+ AuthenticatedAccount authentication) =>
+ context.Response.Cookies.Append(
+ sessions.CookieName,
+ authentication.SessionToken,
+ SessionService.CreateCookieOptions(authentication.ExpiresAt));
+
+ private static AccountResponse ToResponse(AccountRecord account) =>
+ new(account.AccountName, account.Email, account.CreatedAt);
+}
diff --git a/apps/api/Accounts/AccountNamePolicy.cs b/apps/api/Accounts/AccountNamePolicy.cs
new file mode 100644
index 0000000..7bc6c1b
--- /dev/null
+++ b/apps/api/Accounts/AccountNamePolicy.cs
@@ -0,0 +1,46 @@
+using System.Collections.Frozen;
+using System.Text.RegularExpressions;
+
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+internal static partial class AccountNamePolicy
+{
+ public const int MinimumLength = 8;
+ public const int MaximumLength = 128;
+
+ // These names identify Progmasoft itself and the initial administrative identity. They are provisioned only by
+ // trusted operations tooling, never by the public registration endpoint.
+ private static readonly FrozenSet<string> ReservedNames =
+ new[] { "LEITWOLF", "PROGMASOFT" }.ToFrozenSet(StringComparer.Ordinal);
+
+ [GeneratedRegex("^[A-Z][A-Za-z0-9]{7,127}$", RegexOptions.CultureInvariant)]
+ private static partial Regex CanonicalPattern();
+
+ public static bool TryNormalize(string? value, out string canonical, out string error)
+ {
+ canonical = value?.Trim() ?? string.Empty;
+
+ if (canonical.Length is < MinimumLength or > MaximumLength)
+ {
+ error = $"Account names must contain {MinimumLength}–{MaximumLength} characters.";
+ return false;
+ }
+
+ if (!CanonicalPattern().IsMatch(canonical))
+ {
+ error = "Account names must begin with an uppercase ASCII letter and contain only ASCII letters or digits.";
+ return false;
+ }
+
+ if (ReservedNames.Contains(NormalizeForLookup(canonical)))
+ {
+ error = "That account name is reserved.";
+ return false;
+ }
+
+ error = string.Empty;
+ return true;
+ }
+
+ public static string NormalizeForLookup(string canonical) => canonical.ToUpperInvariant();
+}
diff --git a/apps/api/Accounts/AccountRecord.cs b/apps/api/Accounts/AccountRecord.cs
new file mode 100644
index 0000000..14b79b1
--- /dev/null
+++ b/apps/api/Accounts/AccountRecord.cs
@@ -0,0 +1,17 @@
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+internal sealed record AccountRecord(
+ Guid Id,
+ string AccountName,
+ string NormalizedAccountName,
+ string Email,
+ string NormalizedEmail,
+ string PasswordHash,
+ DateTimeOffset CreatedAt);
+
+internal sealed record SessionRecord(
+ byte[] TokenDigest,
+ Guid AccountId,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset ExpiresAt);
+
diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs
new file mode 100644
index 0000000..e72f289
--- /dev/null
+++ b/apps/api/Accounts/AccountService.cs
@@ -0,0 +1,110 @@
+using Microsoft.AspNetCore.Identity;
+
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+internal sealed class AccountService(
+ IAccountStore store,
+ TimeProvider timeProvider)
+{
+ private readonly PasswordHasher<AccountRecord> passwordHasher = new();
+
+ public async ValueTask<(AuthenticatedAccount? Authentication, Dictionary<string, string[]> Errors)> RegisterAsync(
+ RegisterAccountRequest request,
+ SessionService sessions,
+ CancellationToken cancellationToken)
+ {
+ Dictionary<string, string[]> errors = ValidateRegistration(request, out string accountName,
+ out string email, out string normalizedEmail);
+ if (errors.Count != 0)
+ {
+ return (null, errors);
+ }
+
+ AccountRecord provisional = new(
+ Guid.NewGuid(),
+ accountName,
+ AccountNamePolicy.NormalizeForLookup(accountName),
+ email,
+ normalizedEmail,
+ string.Empty,
+ timeProvider.GetUtcNow());
+
+ string passwordHash = passwordHasher.HashPassword(provisional, request.Password!);
+ AccountRecord account = provisional with { PasswordHash = passwordHash };
+ CreateAccountResult result = await store.CreateAsync(account, cancellationToken);
+
+ if (result.Status is CreateAccountStatus.AccountNameUnavailable)
+ {
+ return (null, new Dictionary<string, string[]> { ["accountName"] = ["That account name is unavailable."] });
+ }
+
+ if (result.Status is CreateAccountStatus.EmailUnavailable)
+ {
+ return (null, new Dictionary<string, string[]> { ["email"] = ["That email address is already registered."] });
+ }
+
+ AuthenticatedAccount authentication = await sessions.CreateAsync(account, cancellationToken);
+ return (authentication, errors);
+ }
+
+ public async ValueTask<AuthenticatedAccount?> LoginAsync(
+ LoginAccountRequest request,
+ SessionService sessions,
+ CancellationToken cancellationToken)
+ {
+ if (!EmailAddressPolicy.TryNormalize(request.Email, out _, out string normalizedEmail, out _) ||
+ request.Password is null)
+ {
+ return null;
+ }
+
+ AccountRecord? account = await store.FindByEmailAsync(normalizedEmail, cancellationToken);
+ if (account is null)
+ {
+ // A fixed dummy record keeps the missing-account path on the same password-hashing primitive.
+ AccountRecord dummyBase = new(Guid.Empty, "Missing00", "MISSING00", "missing@example.invalid",
+ "MISSING@EXAMPLE.INVALID", string.Empty, DateTimeOffset.UnixEpoch);
+ AccountRecord dummy = dummyBase with
+ {
+ PasswordHash = passwordHasher.HashPassword(dummyBase, "not-the-provided-password")
+ };
+ _ = passwordHasher.VerifyHashedPassword(dummy, dummy.PasswordHash, request.Password);
+ return null;
+ }
+
+ PasswordVerificationResult verification =
+ passwordHasher.VerifyHashedPassword(account, account.PasswordHash, request.Password);
+ if (verification is PasswordVerificationResult.Failed)
+ {
+ return null;
+ }
+
+ return await sessions.CreateAsync(account, cancellationToken);
+ }
+
+ private static Dictionary<string, string[]> ValidateRegistration(
+ RegisterAccountRequest request,
+ out string accountName,
+ out string email,
+ out string normalizedEmail)
+ {
+ Dictionary<string, string[]> errors = [];
+
+ if (!AccountNamePolicy.TryNormalize(request.AccountName, out accountName, out string accountNameError))
+ {
+ errors["accountName"] = [accountNameError];
+ }
+
+ if (!EmailAddressPolicy.TryNormalize(request.Email, out email, out normalizedEmail, out string emailError))
+ {
+ errors["email"] = [emailError];
+ }
+
+ if (!PasswordPolicy.TryValidate(request.Password, out string passwordError))
+ {
+ errors["password"] = [passwordError];
+ }
+
+ return errors;
+ }
+}
diff --git a/apps/api/Accounts/EmailAddressPolicy.cs b/apps/api/Accounts/EmailAddressPolicy.cs
new file mode 100644
index 0000000..a057e6e
--- /dev/null
+++ b/apps/api/Accounts/EmailAddressPolicy.cs
@@ -0,0 +1,39 @@
+using System.Net.Mail;
+
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+internal static class EmailAddressPolicy
+{
+ public const int MaximumLength = 254;
+
+ public static bool TryNormalize(string? value, out string canonical, out string normalized, out string error)
+ {
+ canonical = value?.Trim() ?? string.Empty;
+ normalized = string.Empty;
+
+ if (canonical.Length is 0 or > MaximumLength)
+ {
+ error = "Enter a valid email address.";
+ return false;
+ }
+
+ try
+ {
+ MailAddress parsed = new(canonical);
+ if (!string.Equals(parsed.Address, canonical, StringComparison.OrdinalIgnoreCase))
+ {
+ error = "Enter a single email address without a display name.";
+ return false;
+ }
+ }
+ catch (FormatException)
+ {
+ error = "Enter a valid email address.";
+ return false;
+ }
+
+ normalized = canonical.ToUpperInvariant();
+ error = string.Empty;
+ return true;
+ }
+}
diff --git a/apps/api/Accounts/IAccountStore.cs b/apps/api/Accounts/IAccountStore.cs
new file mode 100644
index 0000000..e5a8b44
--- /dev/null
+++ b/apps/api/Accounts/IAccountStore.cs
@@ -0,0 +1,12 @@
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+internal interface IAccountStore
+{
+ ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken);
+ ValueTask<AccountRecord?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken);
+ ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken);
+ ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken);
+ ValueTask<SessionRecord?> FindSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken);
+ ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken);
+}
+
diff --git a/apps/api/Accounts/InMemoryAccountStore.cs b/apps/api/Accounts/InMemoryAccountStore.cs
new file mode 100644
index 0000000..f4e1418
--- /dev/null
+++ b/apps/api/Accounts/InMemoryAccountStore.cs
@@ -0,0 +1,86 @@
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+// This development store models the uniqueness and session semantics required by the future PostgreSQL implementation.
+// It deliberately exposes no enumeration or bulk-delete surface to public endpoints.
+internal sealed class InMemoryAccountStore : IAccountStore
+{
+ private readonly Lock gate = new();
+ private readonly Dictionary<Guid, AccountRecord> accountsById = [];
+ private readonly Dictionary<string, Guid> accountIdsByEmail = new(StringComparer.Ordinal);
+ private readonly Dictionary<string, Guid> accountIdsByName = new(StringComparer.Ordinal);
+ private readonly Dictionary<string, SessionRecord> sessionsByDigest = new(StringComparer.Ordinal);
+
+ public ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ lock (gate)
+ {
+ if (accountIdsByName.ContainsKey(account.NormalizedAccountName))
+ {
+ return ValueTask.FromResult(CreateAccountResult.AccountNameUnavailable());
+ }
+
+ if (accountIdsByEmail.ContainsKey(account.NormalizedEmail))
+ {
+ return ValueTask.FromResult(CreateAccountResult.EmailUnavailable());
+ }
+
+ accountsById.Add(account.Id, account);
+ accountIdsByName.Add(account.NormalizedAccountName, account.Id);
+ accountIdsByEmail.Add(account.NormalizedEmail, account.Id);
+ return ValueTask.FromResult(CreateAccountResult.Created(account));
+ }
+ }
+
+ public ValueTask<AccountRecord?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (gate)
+ {
+ return ValueTask.FromResult(
+ accountIdsByEmail.TryGetValue(normalizedEmail, out Guid id) ? accountsById[id] : null);
+ }
+ }
+
+ public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (gate)
+ {
+ accountsById.TryGetValue(id, out AccountRecord? account);
+ return ValueTask.FromResult(account);
+ }
+ }
+
+ public ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (gate)
+ {
+ sessionsByDigest[Convert.ToHexString(session.TokenDigest)] = session;
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ public ValueTask<SessionRecord?> FindSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (gate)
+ {
+ sessionsByDigest.TryGetValue(Convert.ToHexString(tokenDigest), out SessionRecord? session);
+ return ValueTask.FromResult(session);
+ }
+ }
+
+ public ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (gate)
+ {
+ sessionsByDigest.Remove(Convert.ToHexString(tokenDigest));
+ return ValueTask.CompletedTask;
+ }
+ }
+}
+
diff --git a/apps/api/Accounts/PasswordPolicy.cs b/apps/api/Accounts/PasswordPolicy.cs
new file mode 100644
index 0000000..f40b220
--- /dev/null
+++ b/apps/api/Accounts/PasswordPolicy.cs
@@ -0,0 +1,31 @@
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+internal static class PasswordPolicy
+{
+ public const int MinimumLength = 12;
+ public const int MaximumLength = 256;
+
+ public static bool TryValidate(string? password, out string error)
+ {
+ if (password is null || password.Length < MinimumLength)
+ {
+ error = $"Passwords must contain at least {MinimumLength} characters.";
+ return false;
+ }
+
+ if (password.Length > MaximumLength)
+ {
+ error = $"Passwords cannot exceed {MaximumLength} characters.";
+ return false;
+ }
+
+ if (password.Contains('\0'))
+ {
+ error = "Passwords cannot contain a null character.";
+ return false;
+ }
+
+ error = string.Empty;
+ return true;
+ }
+}
diff --git a/apps/api/Accounts/SessionService.cs b/apps/api/Accounts/SessionService.cs
new file mode 100644
index 0000000..010b9fb
--- /dev/null
+++ b/apps/api/Accounts/SessionService.cs
@@ -0,0 +1,89 @@
+using System.Security.Cryptography;
+using Microsoft.Extensions.Options;
+
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+internal sealed class AccountSessionOptions
+{
+ public const string SectionName = "AccountSessions";
+ public int LifetimeHours { get; init; } = 12;
+ public string CookieName { get; init; } = "__Host-ProgmasoftSession";
+}
+
+internal sealed class SessionService(
+ IAccountStore store,
+ TimeProvider timeProvider,
+ IOptions<AccountSessionOptions> options)
+{
+ private readonly AccountSessionOptions settings = options.Value;
+
+ public async ValueTask<AuthenticatedAccount> CreateAsync(AccountRecord account, CancellationToken cancellationToken)
+ {
+ byte[] tokenBytes = RandomNumberGenerator.GetBytes(32);
+ string token = Convert.ToBase64String(tokenBytes)
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
+ byte[] digest = SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(token));
+ DateTimeOffset createdAt = timeProvider.GetUtcNow();
+ DateTimeOffset expiresAt = createdAt.AddHours(Math.Clamp(settings.LifetimeHours, 1, 168));
+
+ await store.StoreSessionAsync(new SessionRecord(digest, account.Id, createdAt, expiresAt), cancellationToken);
+ return new AuthenticatedAccount(account, token, expiresAt);
+ }
+
+ public async ValueTask<AccountRecord?> AuthenticateAsync(string? token, CancellationToken cancellationToken)
+ {
+ if (!TryDigest(token, out byte[] digest))
+ {
+ return null;
+ }
+
+ SessionRecord? session = await store.FindSessionAsync(digest, cancellationToken);
+ if (session is null)
+ {
+ return null;
+ }
+
+ if (session.ExpiresAt <= timeProvider.GetUtcNow())
+ {
+ await store.RevokeSessionAsync(digest, cancellationToken);
+ return null;
+ }
+
+ return await store.FindByIdAsync(session.AccountId, cancellationToken);
+ }
+
+ public async ValueTask RevokeAsync(string? token, CancellationToken cancellationToken)
+ {
+ if (TryDigest(token, out byte[] digest))
+ {
+ await store.RevokeSessionAsync(digest, cancellationToken);
+ }
+ }
+
+ public static CookieOptions CreateCookieOptions(DateTimeOffset expiresAt) => new()
+ {
+ Expires = expiresAt,
+ HttpOnly = true,
+ IsEssential = true,
+ Path = "/",
+ SameSite = SameSiteMode.Lax,
+ Secure = true
+ };
+
+ public string CookieName => settings.CookieName;
+
+ private static bool TryDigest(string? token, out byte[] digest)
+ {
+ digest = [];
+ if (token is null || token.Length is < 40 or > 64 || token.Any(character =>
+ !(char.IsAsciiLetterOrDigit(character) || character is '-' or '_')))
+ {
+ return false;
+ }
+
+ digest = SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(token));
+ return true;
+ }
+}
diff --git a/apps/api/Progmaweb.Api.csproj b/apps/api/Progmaweb.Api.csproj
new file mode 100644
index 0000000..6412f37
--- /dev/null
+++ b/apps/api/Progmaweb.Api.csproj
@@ -0,0 +1,8 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <RootNamespace>Progmasoft.Progmaweb.Api</RootNamespace>
+ <AssemblyName>Progmaweb.Api</AssemblyName>
+ <Version>1.0.0</Version>
+ </PropertyGroup>
+</Project>
diff --git a/apps/api/Program.cs b/apps/api/Program.cs
new file mode 100644
index 0000000..5ca2e17
--- /dev/null
+++ b/apps/api/Program.cs
@@ -0,0 +1,64 @@
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.HttpOverrides;
+using Progmasoft.Progmaweb.Api.Accounts;
+using Progmasoft.Progmaweb.Api.Security;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.Services.Configure<AccountSessionOptions>(builder.Configuration.GetSection(AccountSessionOptions.SectionName));
+builder.Services.AddSingleton(TimeProvider.System);
+builder.Services.AddSingleton<IAccountStore, InMemoryAccountStore>();
+builder.Services.AddSingleton<AccountService>();
+builder.Services.AddSingleton<SessionService>();
+builder.Services.AddProblemDetails();
+builder.Services.AddHealthChecks();
+builder.Services.AddRateLimiter(options =>
+{
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ options.AddPolicy("account-auth", context =>
+ {
+ string partition = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ return RateLimitPartition.GetSlidingWindowLimiter(partition, _ => new SlidingWindowRateLimiterOptions
+ {
+ AutoReplenishment = true,
+ PermitLimit = 12,
+ QueueLimit = 0,
+ SegmentsPerWindow = 6,
+ Window = TimeSpan.FromMinutes(3)
+ });
+ });
+});
+
+builder.Services.Configure<ForwardedHeadersOptions>(options =>
+{
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedHost |
+ ForwardedHeaders.XForwardedProto;
+ options.KnownIPNetworks.Clear();
+ options.KnownProxies.Clear();
+});
+
+WebApplication app = builder.Build();
+
+app.UseForwardedHeaders();
+if (!app.Environment.IsDevelopment())
+{
+ app.UseHsts();
+}
+
+app.UseHttpsRedirection();
+app.UseMiddleware<SecurityHeadersMiddleware>();
+app.UseRateLimiter();
+app.UseExceptionHandler();
+
+app.MapHealthChecks("/health");
+app.MapGet("/api/v1/status", () => Results.Ok(new
+{
+ service = "progmaweb-account",
+ status = "online",
+ version = "1.0.0"
+}));
+app.MapAccountEndpoints();
+
+app.Run();
+
+public partial class Program;
diff --git a/apps/api/Security/SecurityHeadersMiddleware.cs b/apps/api/Security/SecurityHeadersMiddleware.cs
new file mode 100644
index 0000000..fbb555f
--- /dev/null
+++ b/apps/api/Security/SecurityHeadersMiddleware.cs
@@ -0,0 +1,18 @@
+namespace Progmasoft.Progmaweb.Api.Security;
+
+internal sealed class SecurityHeadersMiddleware(RequestDelegate next)
+{
+ public async Task InvokeAsync(HttpContext context)
+ {
+ IHeaderDictionary headers = context.Response.Headers;
+ headers["Content-Security-Policy"] =
+ "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
+ headers["Referrer-Policy"] = "no-referrer";
+ headers["X-Content-Type-Options"] = "nosniff";
+ headers["X-Frame-Options"] = "DENY";
+ headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
+ headers["Cache-Control"] = "no-store";
+
+ await next(context);
+ }
+}
diff --git a/apps/api/appsettings.Development.json b/apps/api/appsettings.Development.json
new file mode 100644
index 0000000..cedca2b
--- /dev/null
+++ b/apps/api/appsettings.Development.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Information"
+ }
+ },
+ "AllowedHosts": "localhost;127.0.0.1;account.localhost"
+}
+
diff --git a/apps/api/appsettings.json b/apps/api/appsettings.json
new file mode 100644
index 0000000..03162c5
--- /dev/null
+++ b/apps/api/appsettings.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "localhost;account.progmasoft.com;www.progmasoft.com",
+ "AccountSessions": {
+ "LifetimeHours": 12,
+ "CookieName": "__Host-ProgmasoftSession"
+ }
+}
+
diff --git a/apps/api/packages.lock.json b/apps/api/packages.lock.json
new file mode 100644
index 0000000..4a91a8c
--- /dev/null
+++ b/apps/api/packages.lock.json
@@ -0,0 +1,6 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {}
+ }
+} \ No newline at end of file