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,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 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<SurrealDbSettings>(configuration.GetSection("SurrealDb"));
|
||||
services.Configure<AuthSettings>(configuration.GetSection("Auth"));
|
||||
|
||||
var settings = configuration.GetSection("SurrealDb").Get<SurrealDbSettings>()
|
||||
?? new SurrealDbSettings();
|
||||
@@ -46,6 +48,10 @@ public static class DependencyInjection
|
||||
|
||||
services.AddSingleton<IStorageService, StorageService>();
|
||||
services.AddSingleton<IMigrationRunner, MigrationRunner>();
|
||||
services.AddSingleton<ITokenService, TokenService>();
|
||||
services.AddSingleton<IUserService, UserService>();
|
||||
services.AddSingleton<IConsentService, ConsentService>();
|
||||
services.AddSingleton<IAuditLogService, AuditLogService>();
|
||||
|
||||
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="SurrealDb.Embedded.RocksDb" Version="1.0.0" />
|
||||
<PackageReference Include="SurrealDb.Net" Version="1.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user