From 482a4b41f0972c3793dcf2c5eb017e1eff5c3628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ty=C5=84ski?= Date: Sun, 4 Oct 2026 02:11:56 +0200 Subject: [PATCH] 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. --- .../Abstractions/IAuditLogService.cs | 11 ++ .../Abstractions/IConsentService.cs | 20 +++ .../Application/Abstractions/ITokenService.cs | 10 ++ .../Application/Abstractions/IUserService.cs | 15 +++ backend/Application/Dtos/AuthDtos.cs | 18 +++ backend/Application/Dtos/ConsentDtos.cs | 11 ++ backend/Application/Mappings/UserMappings.cs | 14 +++ backend/Application/Models/UserAccount.cs | 8 ++ .../Security/ClaimsPrincipalExtensions.cs | 12 ++ .../Application/Security/SecurityConstants.cs | 47 ++++++++ backend/Controllers/AuthController.cs | 83 +++++++++++++ backend/Controllers/ConsentsController.cs | 98 +++++++++++++++ backend/Controllers/UsersController.cs | 73 +++++++++++ backend/Infrastructure/Auth/AuthSettings.cs | 18 +++ backend/Infrastructure/Auth/TokenService.cs | 58 +++++++++ backend/Infrastructure/DependencyInjection.cs | 6 + .../Persistence/AuditLogService.cs | 47 ++++++++ .../Persistence/ConsentService.cs | 114 ++++++++++++++++++ .../Persistence/SurrealRecordId.cs | 13 ++ .../Infrastructure/Persistence/SurrealRows.cs | 36 ++++++ .../Infrastructure/Persistence/UserService.cs | 114 ++++++++++++++++++ .../TarninaHub.Infrastructure.csproj | 1 + backend/Program.cs | 57 +++++++++ backend/TarninaHub.csproj | 1 + backend/appsettings.Development.json | 6 + backend/appsettings.json | 5 + 26 files changed, 896 insertions(+) create mode 100644 backend/Application/Abstractions/IAuditLogService.cs create mode 100644 backend/Application/Abstractions/IConsentService.cs create mode 100644 backend/Application/Abstractions/ITokenService.cs create mode 100644 backend/Application/Abstractions/IUserService.cs create mode 100644 backend/Application/Dtos/AuthDtos.cs create mode 100644 backend/Application/Dtos/ConsentDtos.cs create mode 100644 backend/Application/Mappings/UserMappings.cs create mode 100644 backend/Application/Models/UserAccount.cs create mode 100644 backend/Application/Security/ClaimsPrincipalExtensions.cs create mode 100644 backend/Application/Security/SecurityConstants.cs create mode 100644 backend/Controllers/AuthController.cs create mode 100644 backend/Controllers/ConsentsController.cs create mode 100644 backend/Controllers/UsersController.cs create mode 100644 backend/Infrastructure/Auth/AuthSettings.cs create mode 100644 backend/Infrastructure/Auth/TokenService.cs create mode 100644 backend/Infrastructure/Persistence/AuditLogService.cs create mode 100644 backend/Infrastructure/Persistence/ConsentService.cs create mode 100644 backend/Infrastructure/Persistence/SurrealRecordId.cs create mode 100644 backend/Infrastructure/Persistence/SurrealRows.cs create mode 100644 backend/Infrastructure/Persistence/UserService.cs diff --git a/backend/Application/Abstractions/IAuditLogService.cs b/backend/Application/Abstractions/IAuditLogService.cs new file mode 100644 index 0000000..d7626a9 --- /dev/null +++ b/backend/Application/Abstractions/IAuditLogService.cs @@ -0,0 +1,11 @@ +namespace TarninaHub.Application.Abstractions; + +public interface IAuditLogService +{ + Task RecordAsync( + string? actorId, + string action, + string target, + IReadOnlyDictionary context, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/IConsentService.cs b/backend/Application/Abstractions/IConsentService.cs new file mode 100644 index 0000000..721b54a --- /dev/null +++ b/backend/Application/Abstractions/IConsentService.cs @@ -0,0 +1,20 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IConsentService +{ + Task GrantAsync( + string userId, + GrantConsentRequest request, + CancellationToken cancellationToken); + + Task> ListForUserAsync( + string userId, + CancellationToken cancellationToken); + + Task RevokeAsync( + string consentId, + string userId, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Abstractions/ITokenService.cs b/backend/Application/Abstractions/ITokenService.cs new file mode 100644 index 0000000..ebeb5dc --- /dev/null +++ b/backend/Application/Abstractions/ITokenService.cs @@ -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); diff --git a/backend/Application/Abstractions/IUserService.cs b/backend/Application/Abstractions/IUserService.cs new file mode 100644 index 0000000..e026c8b --- /dev/null +++ b/backend/Application/Abstractions/IUserService.cs @@ -0,0 +1,15 @@ +using TarninaHub.Application.Dtos; +using TarninaHub.Application.Models; + +namespace TarninaHub.Application.Abstractions; + +public interface IUserService +{ + Task FindByPhoneAsync(string phone, CancellationToken cancellationToken); + + Task GetByIdAsync(string userId, CancellationToken cancellationToken); + + Task CreateAsync(RegisterRequest request, string role, CancellationToken cancellationToken); + + Task VerifyCredentialsAsync(string phone, string password, CancellationToken cancellationToken); +} diff --git a/backend/Application/Dtos/AuthDtos.cs b/backend/Application/Dtos/AuthDtos.cs new file mode 100644 index 0000000..087dfb8 --- /dev/null +++ b/backend/Application/Dtos/AuthDtos.cs @@ -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); diff --git a/backend/Application/Dtos/ConsentDtos.cs b/backend/Application/Dtos/ConsentDtos.cs new file mode 100644 index 0000000..18f1315 --- /dev/null +++ b/backend/Application/Dtos/ConsentDtos.cs @@ -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); diff --git a/backend/Application/Mappings/UserMappings.cs b/backend/Application/Mappings/UserMappings.cs new file mode 100644 index 0000000..9e0bc0a --- /dev/null +++ b/backend/Application/Mappings/UserMappings.cs @@ -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); +} diff --git a/backend/Application/Models/UserAccount.cs b/backend/Application/Models/UserAccount.cs new file mode 100644 index 0000000..85eb64b --- /dev/null +++ b/backend/Application/Models/UserAccount.cs @@ -0,0 +1,8 @@ +namespace TarninaHub.Application.Models; + +public sealed record UserAccount( + string Id, + string Phone, + string? Email, + string Role, + string? OrganizationId); diff --git a/backend/Application/Security/ClaimsPrincipalExtensions.cs b/backend/Application/Security/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..0f8dd97 --- /dev/null +++ b/backend/Application/Security/ClaimsPrincipalExtensions.cs @@ -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; +} diff --git a/backend/Application/Security/SecurityConstants.cs b/backend/Application/Security/SecurityConstants.cs new file mode 100644 index 0000000..806d771 --- /dev/null +++ b/backend/Application/Security/SecurityConstants.cs @@ -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 All = + [User, Jst, Operator, Manager, Admin]; + + public static bool IsValid(string role) => All.Contains(role); +} + +public static class Policies +{ + /// operator, manager, admin — ROPS/expert staff. + public const string Staff = "Staff"; + + /// operator, admin. + public const string OperatorOrAdmin = "OperatorOrAdmin"; + + /// manager, operator, admin. + public const string ManagerOrAbove = "ManagerOrAbove"; + + /// admin only. + public const string AdminOnly = "AdminOnly"; + + /// jst and above (jst, operator, manager, admin). + public const string JstOrAbove = "JstOrAbove"; +} + +public static class ConsentScopes +{ + /// Access granted by the data subject's consent. + public const string ByConsent = "by-consent"; + + /// Time-limited access (uses consent.expires_at). + public const string Temporary = "temporary"; + + /// Fully anonymized data (no personal identifiers). + public const string Anonymized = "anonymized"; + + public static readonly IReadOnlyList All = [ByConsent, Temporary, Anonymized]; +} diff --git a/backend/Controllers/AuthController.cs b/backend/Controllers/AuthController.cs new file mode 100644 index 0000000..e31ab90 --- /dev/null +++ b/backend/Controllers/AuthController.cs @@ -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> 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> 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())); + } +} diff --git a/backend/Controllers/ConsentsController.cs b/backend/Controllers/ConsentsController.cs new file mode 100644 index 0000000..e7664b3 --- /dev/null +++ b/backend/Controllers/ConsentsController.cs @@ -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> 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 { ["scope"] = request.Scope }, + cancellationToken); + + return StatusCode(StatusCodes.Status201Created, consent); + } + + [HttpGet("mine")] + public async Task>> 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 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(), + cancellationToken); + + return NoContent(); + } +} diff --git a/backend/Controllers/UsersController.cs b/backend/Controllers/UsersController.cs new file mode 100644 index 0000000..2b62e49 --- /dev/null +++ b/backend/Controllers/UsersController.cs @@ -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> 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> 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 { ["reason"] = "staff-lookup" }, + cancellationToken); + } + + return Ok(user.ToResponse()); + } +} diff --git a/backend/Infrastructure/Auth/AuthSettings.cs b/backend/Infrastructure/Auth/AuthSettings.cs new file mode 100644 index 0000000..c3e3888 --- /dev/null +++ b/backend/Infrastructure/Auth/AuthSettings.cs @@ -0,0 +1,18 @@ +namespace TarninaHub.Infrastructure.Auth; + +public sealed class AuthSettings +{ + public string Issuer { get; set; } = "tarninahub"; + + public string Audience { get; set; } = "tarninahub"; + + /// + /// HMAC signing key (min. 32 bytes). Must come from configuration/secrets + /// (env `Auth__SigningKey`) — never hardcoded for production environments. + /// + public string SigningKey { get; set; } = string.Empty; + + public int TokenLifetimeMinutes { get; set; } = 60; + + public bool HasUsableSigningKey => SigningKey.Length >= 32; +} diff --git a/backend/Infrastructure/Auth/TokenService.cs b/backend/Infrastructure/Auth/TokenService.cs new file mode 100644 index 0000000..6864ebe --- /dev/null +++ b/backend/Infrastructure/Auth/TokenService.cs @@ -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 settings) + { + _settings = settings.Value; + } + + public TokenResult CreateToken(UserAccount user) + { + var issuedAt = DateTimeOffset.UtcNow; + var expiresAt = issuedAt.AddMinutes(_settings.TokenLifetimeMinutes); + + var claims = new List + { + 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); + } +} diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs index 51897e6..a372425 100644 --- a/backend/Infrastructure/DependencyInjection.cs +++ b/backend/Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SurrealDb.Net; using TarninaHub.Application.Abstractions; +using TarninaHub.Infrastructure.Auth; using TarninaHub.Infrastructure.Persistence; using TarninaHub.Infrastructure.Settings; @@ -14,6 +15,7 @@ public static class DependencyInjection IConfiguration configuration) { services.Configure(configuration.GetSection("SurrealDb")); + services.Configure(configuration.GetSection("Auth")); var settings = configuration.GetSection("SurrealDb").Get() ?? new SurrealDbSettings(); @@ -46,6 +48,10 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/backend/Infrastructure/Persistence/AuditLogService.cs b/backend/Infrastructure/Persistence/AuditLogService.cs new file mode 100644 index 0000000..1a6b487 --- /dev/null +++ b/backend/Infrastructure/Persistence/AuditLogService.cs @@ -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 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 + { + ["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(); + } +} diff --git a/backend/Infrastructure/Persistence/ConsentService.cs b/backend/Infrastructure/Persistence/ConsentService.cs new file mode 100644 index 0000000..cd5b4ec --- /dev/null +++ b/backend/Infrastructure/Persistence/ConsentService.cs @@ -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 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 + { + ["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>(1) ?? []; + + return Map(rows[0]); + } + + public async Task> 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 + { + ["userTable"] = userTable, + ["userId"] = userIdPart, + }, + cancellationToken); + + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Select(Map).ToList(); + } + + public async Task 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 + { + ["consentTable"] = consentTable, + ["consentIdPart"] = consentIdPart, + ["userTable"] = userTable, + ["userId"] = userIdPart, + }, + cancellationToken); + + response.EnsureAllOks(); + + var rows = response.GetValue>(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); +} diff --git a/backend/Infrastructure/Persistence/SurrealRecordId.cs b/backend/Infrastructure/Persistence/SurrealRecordId.cs new file mode 100644 index 0000000..ea13132 --- /dev/null +++ b/backend/Infrastructure/Persistence/SurrealRecordId.cs @@ -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)..]); + } +} diff --git a/backend/Infrastructure/Persistence/SurrealRows.cs b/backend/Infrastructure/Persistence/SurrealRows.cs new file mode 100644 index 0000000..b3d74c6 --- /dev/null +++ b/backend/Infrastructure/Persistence/SurrealRows.cs @@ -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; } +} diff --git a/backend/Infrastructure/Persistence/UserService.cs b/backend/Infrastructure/Persistence/UserService.cs new file mode 100644 index 0000000..73532ab --- /dev/null +++ b/backend/Infrastructure/Persistence/UserService.cs @@ -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 FindByPhoneAsync(string phone, CancellationToken cancellationToken) + { + var response = await _client.RawQuery( + $"SELECT {SelectUserFields} FROM user WHERE phone = $phone LIMIT 1;", + new Dictionary { ["phone"] = phone }, + cancellationToken); + + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + public async Task 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 { ["table"] = table, ["id"] = id }, + cancellationToken); + + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + public async Task CreateAsync( + RegisterRequest request, + string role, + CancellationToken cancellationToken) + { + var emailClause = string.IsNullOrWhiteSpace(request.Email) ? string.Empty : ", email = $email"; + + var parameters = new Dictionary + { + ["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>(1) ?? []; + + return rows.Count > 0 ? Map(rows[0]) : null; + } + + public async Task 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 + { + ["phone"] = phone, + ["password"] = password, + }, + cancellationToken); + + response.EnsureAllOks(); + + var rows = response.GetValue>(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; +} diff --git a/backend/Infrastructure/TarninaHub.Infrastructure.csproj b/backend/Infrastructure/TarninaHub.Infrastructure.csproj index ee76d1b..87603a5 100644 --- a/backend/Infrastructure/TarninaHub.Infrastructure.csproj +++ b/backend/Infrastructure/TarninaHub.Infrastructure.csproj @@ -10,6 +10,7 @@ + diff --git a/backend/Program.cs b/backend/Program.cs index 3793e3b..5412fa2 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,17 +1,71 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text; using Asp.Versioning; using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using Swashbuckle.AspNetCore.SwaggerGen; using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Security; using TarninaHub.Configuration; using TarninaHub.Errors; using TarninaHub.Infrastructure; +using TarninaHub.Infrastructure.Auth; var builder = WebApplication.CreateBuilder(args); // Layered infrastructure: SurrealDB client, settings and persistence services. builder.Services.AddInfrastructure(builder.Configuration); +// Authentication (JWT bearer) and role-based authorization policies. +var authSettings = builder.Configuration.GetSection("Auth").Get() ?? 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(); // Uniform error responses (RFC 7807) and a global exception handler. @@ -76,6 +130,9 @@ if (!app.Environment.IsDevelopment()) app.UseDefaultFiles(); app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.MapFallbackToFile("index.html"); diff --git a/backend/TarninaHub.csproj b/backend/TarninaHub.csproj index 1081262..ba16adb 100644 --- a/backend/TarninaHub.csproj +++ b/backend/TarninaHub.csproj @@ -26,6 +26,7 @@ + diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 3f9ebab..793d15c 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -5,6 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, + "Auth": { + "Issuer": "tarninahub", + "Audience": "tarninahub", + "TokenLifetimeMinutes": 60, + "SigningKey": "dev-only-signing-key-0123456789-abcdefghijklmnopqrstuvwxyz" + }, "SurrealDb": { "Namespace": "hubmi", "Database": "hubmi", diff --git a/backend/appsettings.json b/backend/appsettings.json index 8a8ce34..fa09370 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -6,6 +6,11 @@ } }, "AllowedHosts": "*", + "Auth": { + "Issuer": "tarninahub", + "Audience": "tarninahub", + "TokenLifetimeMinutes": 60 + }, "SurrealDb": { "Namespace": "hubmi", "Database": "hubmi",