feat(backend): add JWT authentication, RBAC and consent/audit services

Add signup/signin with backend-issued JWT (Argon2 passwords in SurrealDB), role-based authorization policies, per-user data access levels, consent register and read audit log. Protect user endpoints and record sensitive reads.
This commit is contained in:
Rafał Tyński
2026-10-04 02:11:56 +02:00
parent 24c9e582c9
commit 482a4b41f0
26 changed files with 896 additions and 0 deletions
@@ -0,0 +1,11 @@
namespace TarninaHub.Application.Abstractions;
public interface IAuditLogService
{
Task RecordAsync(
string? actorId,
string action,
string target,
IReadOnlyDictionary<string, object?> context,
CancellationToken cancellationToken);
}
@@ -0,0 +1,20 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IConsentService
{
Task<ConsentResponse> GrantAsync(
string userId,
GrantConsentRequest request,
CancellationToken cancellationToken);
Task<IReadOnlyList<ConsentResponse>> ListForUserAsync(
string userId,
CancellationToken cancellationToken);
Task<bool> RevokeAsync(
string consentId,
string userId,
CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
using TarninaHub.Application.Models;
namespace TarninaHub.Application.Abstractions;
public interface ITokenService
{
TokenResult CreateToken(UserAccount user);
}
public sealed record TokenResult(string AccessToken, DateTimeOffset ExpiresAt);
@@ -0,0 +1,15 @@
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Models;
namespace TarninaHub.Application.Abstractions;
public interface IUserService
{
Task<UserAccount?> FindByPhoneAsync(string phone, CancellationToken cancellationToken);
Task<UserAccount?> GetByIdAsync(string userId, CancellationToken cancellationToken);
Task<UserAccount?> CreateAsync(RegisterRequest request, string role, CancellationToken cancellationToken);
Task<bool> VerifyCredentialsAsync(string phone, string password, CancellationToken cancellationToken);
}
+18
View File
@@ -0,0 +1,18 @@
namespace TarninaHub.Application.Dtos;
public sealed record RegisterRequest(string Phone, string? Email, string Password);
public sealed record LoginRequest(string Phone, string Password);
public sealed record UserResponse(
string Id,
string Phone,
string? Email,
string Role,
string? OrganizationId);
public sealed record TokenResponse(
string AccessToken,
string TokenType,
DateTimeOffset ExpiresAt,
UserResponse User);
+11
View File
@@ -0,0 +1,11 @@
namespace TarninaHub.Application.Dtos;
public sealed record GrantConsentRequest(string Scope, DateTimeOffset? ExpiresAt);
public sealed record ConsentResponse(
string Id,
string UserId,
string Scope,
DateTimeOffset GrantedAt,
DateTimeOffset? ExpiresAt,
DateTimeOffset? RevokedAt);
@@ -0,0 +1,14 @@
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Models;
namespace TarninaHub.Application.Mappings;
public static class UserMappings
{
public static UserResponse ToResponse(this UserAccount user) => new(
user.Id,
user.Phone,
user.Email,
user.Role,
user.OrganizationId);
}
@@ -0,0 +1,8 @@
namespace TarninaHub.Application.Models;
public sealed record UserAccount(
string Id,
string Phone,
string? Email,
string Role,
string? OrganizationId);
@@ -0,0 +1,12 @@
using System.Security.Claims;
namespace TarninaHub.Application.Security;
public static class ClaimsPrincipalExtensions
{
public static string? GetUserId(this ClaimsPrincipal principal) =>
principal.FindFirst("sub")?.Value;
public static string? GetRole(this ClaimsPrincipal principal) =>
principal.FindFirst("role")?.Value;
}
@@ -0,0 +1,47 @@
namespace TarninaHub.Application.Security;
public static class RoleNames
{
public const string User = "user";
public const string Jst = "jst";
public const string Operator = "operator";
public const string Manager = "manager";
public const string Admin = "admin";
public static readonly IReadOnlyList<string> All =
[User, Jst, Operator, Manager, Admin];
public static bool IsValid(string role) => All.Contains(role);
}
public static class Policies
{
/// <summary>operator, manager, admin — ROPS/expert staff.</summary>
public const string Staff = "Staff";
/// <summary>operator, admin.</summary>
public const string OperatorOrAdmin = "OperatorOrAdmin";
/// <summary>manager, operator, admin.</summary>
public const string ManagerOrAbove = "ManagerOrAbove";
/// <summary>admin only.</summary>
public const string AdminOnly = "AdminOnly";
/// <summary>jst and above (jst, operator, manager, admin).</summary>
public const string JstOrAbove = "JstOrAbove";
}
public static class ConsentScopes
{
/// <summary>Access granted by the data subject's consent.</summary>
public const string ByConsent = "by-consent";
/// <summary>Time-limited access (uses consent.expires_at).</summary>
public const string Temporary = "temporary";
/// <summary>Fully anonymized data (no personal identifiers).</summary>
public const string Anonymized = "anonymized";
public static readonly IReadOnlyList<string> All = [ByConsent, Temporary, Anonymized];
}
+83
View File
@@ -0,0 +1,83 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Mappings;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly IUserService _userService;
private readonly ITokenService _tokenService;
public AuthController(IUserService userService, ITokenService tokenService)
{
_userService = userService;
_tokenService = tokenService;
}
[HttpPost("signup")]
[AllowAnonymous]
public async Task<ActionResult<UserResponse>> SignUp(
RegisterRequest request,
CancellationToken cancellationToken)
{
var existing = await _userService.FindByPhoneAsync(request.Phone, cancellationToken);
if (existing is not null)
{
return Conflict(new ProblemDetails
{
Title = "Phone number already registered",
Status = StatusCodes.Status409Conflict,
});
}
var user = await _userService.CreateAsync(request, RoleNames.User, cancellationToken);
if (user is null)
{
return Problem(title: "User could not be created", statusCode: StatusCodes.Status400BadRequest);
}
return CreatedAtAction("GetById", "Users", new { userId = user.Id }, user.ToResponse());
}
[HttpPost("signin")]
[AllowAnonymous]
public async Task<ActionResult<TokenResponse>> SignIn(
LoginRequest request,
CancellationToken cancellationToken)
{
var valid = await _userService.VerifyCredentialsAsync(
request.Phone,
request.Password,
cancellationToken);
if (!valid)
{
return Unauthorized(new ProblemDetails
{
Title = "Invalid phone number or password",
Status = StatusCodes.Status401Unauthorized,
});
}
var user = await _userService.FindByPhoneAsync(request.Phone, cancellationToken);
if (user is null)
{
return Unauthorized();
}
var token = _tokenService.CreateToken(user);
return Ok(new TokenResponse(token.AccessToken, "Bearer", token.ExpiresAt, user.ToResponse()));
}
}
+98
View File
@@ -0,0 +1,98 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/consents")]
[Authorize]
public class ConsentsController : ControllerBase
{
private readonly IConsentService _consentService;
private readonly IAuditLogService _auditLogService;
public ConsentsController(IConsentService consentService, IAuditLogService auditLogService)
{
_consentService = consentService;
_auditLogService = auditLogService;
}
[HttpPost]
public async Task<ActionResult<ConsentResponse>> Grant(
GrantConsentRequest request,
CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
if (!ConsentScopes.All.Contains(request.Scope))
{
ModelState.AddModelError(nameof(request.Scope),
$"Scope must be one of: {string.Join(", ", ConsentScopes.All)}.");
return ValidationProblem(ModelState);
}
var consent = await _consentService.GrantAsync(userId, request, cancellationToken);
await _auditLogService.RecordAsync(
userId,
"consent.grant",
consent.Id,
new Dictionary<string, object?> { ["scope"] = request.Scope },
cancellationToken);
return StatusCode(StatusCodes.Status201Created, consent);
}
[HttpGet("mine")]
public async Task<ActionResult<IReadOnlyList<ConsentResponse>>> GetMine(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var consents = await _consentService.ListForUserAsync(userId, cancellationToken);
return Ok(consents);
}
[HttpPost("{consentId}/revoke")]
public async Task<IActionResult> Revoke(string consentId, CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var revoked = await _consentService.RevokeAsync(consentId, userId, cancellationToken);
if (!revoked)
{
return NotFound();
}
await _auditLogService.RecordAsync(
userId,
"consent.revoke",
consentId,
new Dictionary<string, object?>(),
cancellationToken);
return NoContent();
}
}
+73
View File
@@ -0,0 +1,73 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Mappings;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/users")]
[Authorize]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
private readonly IAuditLogService _auditLogService;
public UsersController(IUserService userService, IAuditLogService auditLogService)
{
_userService = userService;
_auditLogService = auditLogService;
}
[HttpGet("me")]
public async Task<ActionResult<UserResponse>> GetMe(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var user = await _userService.GetByIdAsync(userId, cancellationToken);
return user is null ? NotFound() : Ok(user.ToResponse());
}
[HttpGet("{userId}")]
public async Task<ActionResult<UserResponse>> GetById(
string userId,
CancellationToken cancellationToken)
{
var currentUserId = User.GetUserId();
var isOwner = string.Equals(currentUserId, userId, StringComparison.Ordinal);
if (!isOwner && !User.IsInRole(RoleNames.Operator) && !User.IsInRole(RoleNames.Manager) && !User.IsInRole(RoleNames.Admin))
{
return Forbid();
}
var user = await _userService.GetByIdAsync(userId, cancellationToken);
if (user is null)
{
return NotFound();
}
if (!isOwner)
{
await _auditLogService.RecordAsync(
currentUserId,
"user.read",
userId,
new Dictionary<string, object?> { ["reason"] = "staff-lookup" },
cancellationToken);
}
return Ok(user.ToResponse());
}
}
@@ -0,0 +1,18 @@
namespace TarninaHub.Infrastructure.Auth;
public sealed class AuthSettings
{
public string Issuer { get; set; } = "tarninahub";
public string Audience { get; set; } = "tarninahub";
/// <summary>
/// HMAC signing key (min. 32 bytes). Must come from configuration/secrets
/// (env `Auth__SigningKey`) — never hardcoded for production environments.
/// </summary>
public string SigningKey { get; set; } = string.Empty;
public int TokenLifetimeMinutes { get; set; } = 60;
public bool HasUsableSigningKey => SigningKey.Length >= 32;
}
@@ -0,0 +1,58 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Models;
namespace TarninaHub.Infrastructure.Auth;
public sealed class TokenService : ITokenService
{
private readonly AuthSettings _settings;
public TokenService(IOptions<AuthSettings> settings)
{
_settings = settings.Value;
}
public TokenResult CreateToken(UserAccount user)
{
var issuedAt = DateTimeOffset.UtcNow;
var expiresAt = issuedAt.AddMinutes(_settings.TokenLifetimeMinutes);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
new("role", user.Role),
new("phone", user.Phone),
};
if (!string.IsNullOrWhiteSpace(user.Email))
{
claims.Add(new Claim("email", user.Email));
}
if (!string.IsNullOrWhiteSpace(user.OrganizationId))
{
claims.Add(new Claim("organization", user.OrganizationId));
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _settings.Issuer,
audience: _settings.Audience,
claims: claims,
notBefore: issuedAt.UtcDateTime,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials);
var accessToken = new JwtSecurityTokenHandler().WriteToken(token);
return new TokenResult(accessToken, expiresAt);
}
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using SurrealDb.Net; using SurrealDb.Net;
using TarninaHub.Application.Abstractions; using TarninaHub.Application.Abstractions;
using TarninaHub.Infrastructure.Auth;
using TarninaHub.Infrastructure.Persistence; using TarninaHub.Infrastructure.Persistence;
using TarninaHub.Infrastructure.Settings; using TarninaHub.Infrastructure.Settings;
@@ -14,6 +15,7 @@ public static class DependencyInjection
IConfiguration configuration) IConfiguration configuration)
{ {
services.Configure<SurrealDbSettings>(configuration.GetSection("SurrealDb")); services.Configure<SurrealDbSettings>(configuration.GetSection("SurrealDb"));
services.Configure<AuthSettings>(configuration.GetSection("Auth"));
var settings = configuration.GetSection("SurrealDb").Get<SurrealDbSettings>() var settings = configuration.GetSection("SurrealDb").Get<SurrealDbSettings>()
?? new SurrealDbSettings(); ?? new SurrealDbSettings();
@@ -46,6 +48,10 @@ public static class DependencyInjection
services.AddSingleton<IStorageService, StorageService>(); services.AddSingleton<IStorageService, StorageService>();
services.AddSingleton<IMigrationRunner, MigrationRunner>(); services.AddSingleton<IMigrationRunner, MigrationRunner>();
services.AddSingleton<ITokenService, TokenService>();
services.AddSingleton<IUserService, UserService>();
services.AddSingleton<IConsentService, ConsentService>();
services.AddSingleton<IAuditLogService, AuditLogService>();
return services; return services;
} }
@@ -0,0 +1,47 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class AuditLogService : IAuditLogService
{
private readonly ISurrealDbClient _client;
public AuditLogService(ISurrealDbClient client)
{
_client = client;
}
public async Task RecordAsync(
string? actorId,
string action,
string target,
IReadOnlyDictionary<string, object?> context,
CancellationToken cancellationToken)
{
var (actorTable, actorIdPart) = actorId is null
? ("user", (string?)null)
: SurrealRecordId.Split(actorId);
var actorExpression = actorId is null
? "NONE"
: "type::record($actorTable, $actorId)";
var parameters = new Dictionary<string, object?>
{
["action"] = action,
["target"] = target,
["context"] = context,
["actorTable"] = actorTable,
["actorId"] = actorIdPart,
};
var response = await _client.RawQuery(
$"CREATE audit_log SET actor = {actorExpression}, action = $action, " +
"target = $target, context = $context, at = time::now();",
parameters,
cancellationToken);
response.EnsureAllOks();
}
}
@@ -0,0 +1,114 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class ConsentService : IConsentService
{
private const string SelectConsentFields =
"type::string(id) AS id, type::string(user) AS user, scope, granted_at, expires_at, revoked_at";
private readonly ISurrealDbClient _client;
public ConsentService(ISurrealDbClient client)
{
_client = client;
}
public async Task<ConsentResponse> GrantAsync(
string userId,
GrantConsentRequest request,
CancellationToken cancellationToken)
{
var (userTable, userIdPart) = SurrealRecordId.Split(userId);
var consentId = Guid.NewGuid().ToString("N");
var expiresClause = request.ExpiresAt.HasValue ? ", expires_at = $expiresAt" : string.Empty;
var parameters = new Dictionary<string, object?>
{
["consentId"] = consentId,
["userTable"] = userTable,
["userId"] = userIdPart,
["scope"] = request.Scope,
};
if (request.ExpiresAt.HasValue)
{
parameters["expiresAt"] = request.ExpiresAt;
}
var response = await _client.RawQuery(
"CREATE type::record('consent', $consentId) SET " +
"user = type::record($userTable, $userId), scope = $scope, " +
"granted_at = time::now()" + expiresClause + "; " +
$"SELECT {SelectConsentFields} FROM type::record('consent', $consentId) LIMIT 1;",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<ConsentRow>>(1) ?? [];
return Map(rows[0]);
}
public async Task<IReadOnlyList<ConsentResponse>> ListForUserAsync(
string userId,
CancellationToken cancellationToken)
{
var (userTable, userIdPart) = SurrealRecordId.Split(userId);
var response = await _client.RawQuery(
$"SELECT {SelectConsentFields} FROM consent " +
"WHERE user = type::record($userTable, $userId) ORDER BY granted_at DESC;",
new Dictionary<string, object?>
{
["userTable"] = userTable,
["userId"] = userIdPart,
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<ConsentRow>>(0) ?? [];
return rows.Select(Map).ToList();
}
public async Task<bool> RevokeAsync(
string consentId,
string userId,
CancellationToken cancellationToken)
{
var (userTable, userIdPart) = SurrealRecordId.Split(userId);
var (consentTable, consentIdPart) = SurrealRecordId.Split(consentId);
var response = await _client.RawQuery(
"UPDATE type::record($consentTable, $consentIdPart) SET revoked_at = time::now() " +
"WHERE user = type::record($userTable, $userId) AND revoked_at = NONE; " +
$"SELECT {SelectConsentFields} FROM type::record($consentTable, $consentIdPart) LIMIT 1;",
new Dictionary<string, object?>
{
["consentTable"] = consentTable,
["consentIdPart"] = consentIdPart,
["userTable"] = userTable,
["userId"] = userIdPart,
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<ConsentRow>>(1) ?? [];
return rows.Count > 0 && rows[0].RevokedAt is not null;
}
private static ConsentResponse Map(ConsentRow row) => new(
row.Id,
row.User,
row.Scope,
row.GrantedAt,
row.ExpiresAt,
row.RevokedAt);
}
@@ -0,0 +1,13 @@
namespace TarninaHub.Infrastructure.Persistence;
internal static class SurrealRecordId
{
public static (string Table, string Id) Split(string recordId)
{
var separator = recordId.IndexOf(':', StringComparison.Ordinal);
return separator < 0
? (recordId, recordId)
: (recordId[..separator], recordId[(separator + 1)..]);
}
}
@@ -0,0 +1,36 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class UserRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("phone")] public string Phone { get; set; } = string.Empty;
[CborProperty("email")] public string? Email { get; set; }
[CborProperty("role")] public string Role { get; set; } = string.Empty;
[CborProperty("organization")] public string? Organization { get; set; }
}
internal sealed class PasswordCheckRow
{
[CborProperty("password_ok")] public bool PasswordOk { get; set; }
}
internal sealed class ConsentRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("user")] public string User { get; set; } = string.Empty;
[CborProperty("scope")] public string Scope { get; set; } = string.Empty;
[CborProperty("granted_at")] public DateTimeOffset GrantedAt { get; set; }
[CborProperty("expires_at")] public DateTimeOffset? ExpiresAt { get; set; }
[CborProperty("revoked_at")] public DateTimeOffset? RevokedAt { get; set; }
}
@@ -0,0 +1,114 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Models;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class UserService : IUserService
{
private const string SelectUserFields =
"type::string(id) AS id, phone, email, role, type::string(organization) AS organization";
private readonly ISurrealDbClient _client;
public UserService(ISurrealDbClient client)
{
_client = client;
}
public async Task<UserAccount?> FindByPhoneAsync(string phone, CancellationToken cancellationToken)
{
var response = await _client.RawQuery(
$"SELECT {SelectUserFields} FROM user WHERE phone = $phone LIMIT 1;",
new Dictionary<string, object?> { ["phone"] = phone },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<UserRow>>(0) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<UserAccount?> GetByIdAsync(string userId, CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(userId);
var response = await _client.RawQuery(
$"SELECT {SelectUserFields} FROM type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<UserRow>>(0) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<UserAccount?> CreateAsync(
RegisterRequest request,
string role,
CancellationToken cancellationToken)
{
var emailClause = string.IsNullOrWhiteSpace(request.Email) ? string.Empty : ", email = $email";
var parameters = new Dictionary<string, object?>
{
["phone"] = request.Phone,
["password"] = request.Password,
["role"] = role,
};
if (!string.IsNullOrWhiteSpace(request.Email))
{
parameters["email"] = request.Email;
}
var response = await _client.RawQuery(
"CREATE user SET phone = $phone" + emailClause + ", " +
"password = crypto::argon2::generate($password), role = $role; " +
$"SELECT {SelectUserFields} FROM user WHERE phone = $phone LIMIT 1;",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<UserRow>>(1) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<bool> VerifyCredentialsAsync(
string phone,
string password,
CancellationToken cancellationToken)
{
var response = await _client.RawQuery(
"SELECT crypto::argon2::compare(password, $password) AS password_ok " +
"FROM user WHERE phone = $phone LIMIT 1;",
new Dictionary<string, object?>
{
["phone"] = phone,
["password"] = password,
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<PasswordCheckRow>>(0) ?? [];
return rows.Count > 0 && rows[0].PasswordOk;
}
private static UserAccount Map(UserRow row) => new(
row.Id,
row.Phone,
string.IsNullOrWhiteSpace(row.Email) ? null : row.Email,
row.Role,
NormalizeRecordId(row.Organization));
private static string? NormalizeRecordId(string? value) =>
string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value;
}
@@ -10,6 +10,7 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
<PackageReference Include="SurrealDb.Embedded.RocksDb" Version="1.0.0" /> <PackageReference Include="SurrealDb.Embedded.RocksDb" Version="1.0.0" />
<PackageReference Include="SurrealDb.Net" Version="1.0.0" /> <PackageReference Include="SurrealDb.Net" Version="1.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+57
View File
@@ -1,17 +1,71 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Asp.Versioning; using Asp.Versioning;
using Asp.Versioning.ApiExplorer; using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
using TarninaHub.Application.Abstractions; using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Security;
using TarninaHub.Configuration; using TarninaHub.Configuration;
using TarninaHub.Errors; using TarninaHub.Errors;
using TarninaHub.Infrastructure; using TarninaHub.Infrastructure;
using TarninaHub.Infrastructure.Auth;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Layered infrastructure: SurrealDB client, settings and persistence services. // Layered infrastructure: SurrealDB client, settings and persistence services.
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
// Authentication (JWT bearer) and role-based authorization policies.
var authSettings = builder.Configuration.GetSection("Auth").Get<AuthSettings>() ?? new AuthSettings();
if (!authSettings.HasUsableSigningKey && !builder.Environment.IsDevelopment())
{
throw new InvalidOperationException(
"Auth signing key is missing or too short. Configure 'Auth:SigningKey' " +
"(env 'Auth__SigningKey') with at least 32 characters.");
}
var signingKey = authSettings.HasUsableSigningKey
? authSettings.SigningKey
: "development-only-signing-key-please-override-in-production";
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = authSettings.Issuer,
ValidateAudience = true,
ValidAudience = authSettings.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = "role",
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(Policies.Staff,
policy => policy.RequireRole(RoleNames.Operator, RoleNames.Manager, RoleNames.Admin));
options.AddPolicy(Policies.OperatorOrAdmin,
policy => policy.RequireRole(RoleNames.Operator, RoleNames.Admin));
options.AddPolicy(Policies.ManagerOrAbove,
policy => policy.RequireRole(RoleNames.Manager, RoleNames.Operator, RoleNames.Admin));
options.AddPolicy(Policies.AdminOnly,
policy => policy.RequireRole(RoleNames.Admin));
options.AddPolicy(Policies.JstOrAbove,
policy => policy.RequireRole(RoleNames.Jst, RoleNames.Operator, RoleNames.Manager, RoleNames.Admin));
});
builder.Services.AddControllers(); builder.Services.AddControllers();
// Uniform error responses (RFC 7807) and a global exception handler. // Uniform error responses (RFC 7807) and a global exception handler.
@@ -76,6 +130,9 @@ if (!app.Environment.IsDevelopment())
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
+1
View File
@@ -26,6 +26,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="10.2.1" /> <PackageReference Include="Asp.Versioning.Mvc" Version="10.2.1" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" /> <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup> </ItemGroup>
+6
View File
@@ -5,6 +5,12 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"Auth": {
"Issuer": "tarninahub",
"Audience": "tarninahub",
"TokenLifetimeMinutes": 60,
"SigningKey": "dev-only-signing-key-0123456789-abcdefghijklmnopqrstuvwxyz"
},
"SurrealDb": { "SurrealDb": {
"Namespace": "hubmi", "Namespace": "hubmi",
"Database": "hubmi", "Database": "hubmi",
+5
View File
@@ -6,6 +6,11 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Auth": {
"Issuer": "tarninahub",
"Audience": "tarninahub",
"TokenLifetimeMinutes": 60
},
"SurrealDb": { "SurrealDb": {
"Namespace": "hubmi", "Namespace": "hubmi",
"Database": "hubmi", "Database": "hubmi",