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:
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user