version-0.1 #1
@@ -14,6 +14,8 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<UserSecretsId>d10a4485-2ac0-4ba7-bda5-8eb63e417567</UserSecretsId>
|
<UserSecretsId>d10a4485-2ac0-4ba7-bda5-8eb63e417567</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace MicCheck.Api.ApiKeys;
|
|
||||||
|
|
||||||
public class ApiKey
|
|
||||||
{
|
|
||||||
public int Id { get; init; }
|
|
||||||
public required string Key { get; set; }
|
|
||||||
public required string Prefix { get; set; }
|
|
||||||
public required string Name { get; set; }
|
|
||||||
public int OrganizationId { get; init; }
|
|
||||||
public bool IsActive { get; set; }
|
|
||||||
public DateTimeOffset? ExpiresAt { get; set; }
|
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.ApiKeys;
|
|
||||||
|
|
||||||
public static class ApiKeyEndpoints
|
|
||||||
{
|
|
||||||
public static void MapApiKeyEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
var group = app
|
|
||||||
.MapGroup("/api/v1/organisations/{organizationId:int}/api-keys")
|
|
||||||
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
|
|
||||||
.WithTags("ApiKeys");
|
|
||||||
|
|
||||||
group.MapPost("/", async (
|
|
||||||
int organizationId,
|
|
||||||
CreateApiKeyRequest request,
|
|
||||||
ApiKeyService apiKeyService,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var (key, rawKey) = await apiKeyService.CreateAsync(
|
|
||||||
organizationId, request.Name, request.ExpiresAt, ct);
|
|
||||||
|
|
||||||
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
|
|
||||||
}).WithName("CreateApiKey");
|
|
||||||
|
|
||||||
group.MapGet("/", async (
|
|
||||||
int organizationId,
|
|
||||||
ApiKeyService apiKeyService,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var keys = await apiKeyService.ListAsync(organizationId, ct);
|
|
||||||
return Results.Ok(keys.Select(k =>
|
|
||||||
new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt)));
|
|
||||||
}).WithName("ListApiKeys");
|
|
||||||
|
|
||||||
group.MapDelete("/{keyId:int}", async (
|
|
||||||
int organizationId,
|
|
||||||
int keyId,
|
|
||||||
ApiKeyService apiKeyService,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
await apiKeyService.RevokeAsync(organizationId, keyId, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
}).WithName("RevokeApiKey");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.ApiKeys;
|
|
||||||
|
|
||||||
public static class ApiKeyHasher
|
|
||||||
{
|
|
||||||
public static string Hash(string key)
|
|
||||||
{
|
|
||||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
|
||||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GenerateKey()
|
|
||||||
{
|
|
||||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
|
||||||
return Convert.ToBase64String(bytes)
|
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace MicCheck.Api.ApiKeys;
|
|
||||||
|
|
||||||
public record ApiKeyResponse(
|
|
||||||
int Id,
|
|
||||||
string Name,
|
|
||||||
string Prefix,
|
|
||||||
bool IsActive,
|
|
||||||
DateTimeOffset? ExpiresAt,
|
|
||||||
DateTimeOffset CreatedAt);
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using MicCheck.Api.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.ApiKeys;
|
|
||||||
|
|
||||||
public class ApiKeyService(MicCheckDbContext db)
|
|
||||||
{
|
|
||||||
public async Task<(ApiKey Key, string RawKey)> CreateAsync(
|
|
||||||
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var rawKey = ApiKeyHasher.GenerateKey();
|
|
||||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
|
||||||
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
|
|
||||||
|
|
||||||
var apiKey = new ApiKey
|
|
||||||
{
|
|
||||||
Key = hashedKey,
|
|
||||||
Prefix = prefix,
|
|
||||||
Name = name,
|
|
||||||
OrganizationId = organizationId,
|
|
||||||
IsActive = true,
|
|
||||||
ExpiresAt = expiresAt,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
db.ApiKeys.Add(apiKey);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return (apiKey, rawKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return await db.ApiKeys
|
|
||||||
.Where(k => k.OrganizationId == organizationId)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RevokeAsync(int organizationId, int keyId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var key = await db.ApiKeys
|
|
||||||
.FirstOrDefaultAsync(k => k.Id == keyId && k.OrganizationId == organizationId, ct);
|
|
||||||
|
|
||||||
if (key is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
key.IsActive = false;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace MicCheck.Api.ApiKeys;
|
|
||||||
|
|
||||||
public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace MicCheck.Api.ApiKeys;
|
|
||||||
|
|
||||||
public record CreateApiKeyResponse(
|
|
||||||
int Id,
|
|
||||||
string Name,
|
|
||||||
string Key,
|
|
||||||
string Prefix,
|
|
||||||
DateTimeOffset? ExpiresAt);
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using MicCheck.Api.ApiKeys;
|
|
||||||
using MicCheck.Api.Data;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Authentication;
|
|
||||||
|
|
||||||
public class ApiKeyAuthenticationHandler(
|
|
||||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
||||||
ILoggerFactory logger,
|
|
||||||
UrlEncoder encoder,
|
|
||||||
MicCheckDbContext db)
|
|
||||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
|
||||||
{
|
|
||||||
public const string SchemeName = "ApiKey";
|
|
||||||
private const string ApiKeyPrefix = "Api-Key ";
|
|
||||||
|
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
||||||
{
|
|
||||||
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
|
|
||||||
return AuthenticateResult.NoResult();
|
|
||||||
|
|
||||||
var headerValue = authHeader.FirstOrDefault();
|
|
||||||
if (string.IsNullOrWhiteSpace(headerValue) ||
|
|
||||||
!headerValue.StartsWith(ApiKeyPrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return AuthenticateResult.NoResult();
|
|
||||||
|
|
||||||
var rawKey = headerValue[ApiKeyPrefix.Length..].Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(rawKey))
|
|
||||||
return AuthenticateResult.Fail("API key value is empty.");
|
|
||||||
|
|
||||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
|
||||||
|
|
||||||
var apiKey = await db.ApiKeys
|
|
||||||
.FirstOrDefaultAsync(k => k.Key == hashedKey);
|
|
||||||
|
|
||||||
if (apiKey is null)
|
|
||||||
return AuthenticateResult.Fail("Invalid API key.");
|
|
||||||
|
|
||||||
if (!apiKey.IsActive)
|
|
||||||
return AuthenticateResult.Fail("API key is inactive.");
|
|
||||||
|
|
||||||
if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
|
||||||
return AuthenticateResult.Fail("API key has expired.");
|
|
||||||
|
|
||||||
var claims = new[]
|
|
||||||
{
|
|
||||||
new Claim("OrganizationId", apiKey.OrganizationId.ToString()),
|
|
||||||
new Claim("OrganizationRole", "Admin")
|
|
||||||
};
|
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
|
||||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
|
|
||||||
|
|
||||||
return AuthenticateResult.Success(ticket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using MicCheck.Api.Data;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Authentication;
|
|
||||||
|
|
||||||
public class EnvironmentKeyAuthenticationHandler(
|
|
||||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
||||||
ILoggerFactory logger,
|
|
||||||
UrlEncoder encoder,
|
|
||||||
MicCheckDbContext db)
|
|
||||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
|
||||||
{
|
|
||||||
public const string SchemeName = "EnvironmentKey";
|
|
||||||
|
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
||||||
{
|
|
||||||
if (!Request.Headers.TryGetValue("X-Environment-Key", out var keyValues))
|
|
||||||
return AuthenticateResult.NoResult();
|
|
||||||
|
|
||||||
var key = keyValues.FirstOrDefault();
|
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
|
||||||
return AuthenticateResult.Fail("X-Environment-Key header is empty.");
|
|
||||||
|
|
||||||
var environment = await db.Environments
|
|
||||||
.FirstOrDefaultAsync(e => e.ApiKey == key);
|
|
||||||
|
|
||||||
if (environment is null)
|
|
||||||
return AuthenticateResult.Fail("Invalid environment key.");
|
|
||||||
|
|
||||||
var claims = new[]
|
|
||||||
{
|
|
||||||
new Claim("EnvironmentId", environment.Id.ToString()),
|
|
||||||
new Claim("ProjectId", environment.ProjectId.ToString())
|
|
||||||
};
|
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
|
||||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
|
|
||||||
|
|
||||||
return AuthenticateResult.Success(ticket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public static class AuthEndpoints
|
|
||||||
{
|
|
||||||
public static void MapAuthEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
var group = app.MapGroup("/api/v1/auth").AllowAnonymous().WithTags("Auth");
|
|
||||||
|
|
||||||
group.MapPost("/login", async (
|
|
||||||
[FromBody] LoginRequest request,
|
|
||||||
AuthService authService,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
|
||||||
return Results.BadRequest("Email and password are required.");
|
|
||||||
|
|
||||||
var response = await authService.LoginAsync(request.Email, request.Password, ct);
|
|
||||||
return response is null
|
|
||||||
? Results.Unauthorized()
|
|
||||||
: Results.Ok(response);
|
|
||||||
}).WithName("Login");
|
|
||||||
|
|
||||||
group.MapPost("/register", async (
|
|
||||||
[FromBody] RegisterRequest request,
|
|
||||||
AuthService authService,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
|
||||||
return Results.BadRequest("Email and password are required.");
|
|
||||||
|
|
||||||
var response = await authService.RegisterAsync(
|
|
||||||
request.Email, request.Password,
|
|
||||||
request.FirstName, request.LastName,
|
|
||||||
request.OrganizationName, ct);
|
|
||||||
|
|
||||||
return response is null
|
|
||||||
? Results.Conflict("An account with this email already exists.")
|
|
||||||
: Results.Ok(response);
|
|
||||||
}).WithName("Register");
|
|
||||||
|
|
||||||
group.MapPost("/refresh", async (
|
|
||||||
[FromBody] RefreshRequest request,
|
|
||||||
AuthService authService,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Token))
|
|
||||||
return Results.BadRequest("Refresh token is required.");
|
|
||||||
|
|
||||||
var response = await authService.RefreshAsync(request.Token, ct);
|
|
||||||
return response is null
|
|
||||||
? Results.Unauthorized()
|
|
||||||
: Results.Ok(response);
|
|
||||||
}).WithName("RefreshToken");
|
|
||||||
|
|
||||||
group.MapPost("/logout", async (
|
|
||||||
[FromBody] LogoutRequest request,
|
|
||||||
AuthService authService,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
await authService.LogoutAsync(request.Token, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
}).WithName("Logout");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using MicCheck.Api.Data;
|
|
||||||
using MicCheck.Api.Organizations;
|
|
||||||
using MicCheck.Api.Users;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public class AuthService(
|
|
||||||
MicCheckDbContext db,
|
|
||||||
ITokenService tokenService,
|
|
||||||
IPasswordHasher<User> passwordHasher)
|
|
||||||
{
|
|
||||||
public async Task<LoginResponse?> LoginAsync(string email, string password, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var user = await db.Users
|
|
||||||
.Include(u => u.Organizations)
|
|
||||||
.FirstOrDefaultAsync(u => u.Email == email && u.IsActive, ct);
|
|
||||||
|
|
||||||
if (user is null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
|
||||||
if (result == PasswordVerificationResult.Failed)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var accessToken = tokenService.GenerateToken(user);
|
|
||||||
var refreshTokenValue = GenerateSecureToken();
|
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
|
||||||
{
|
|
||||||
Token = refreshTokenValue,
|
|
||||||
UserId = user.Id,
|
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
|
|
||||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<LoginResponse?> RegisterAsync(
|
|
||||||
string email, string password,
|
|
||||||
string firstName, string lastName,
|
|
||||||
string organizationName,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (await db.Users.AnyAsync(u => u.Email == email, ct))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var user = new User
|
|
||||||
{
|
|
||||||
Email = email,
|
|
||||||
FirstName = firstName,
|
|
||||||
LastName = lastName,
|
|
||||||
IsActive = true,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
|
||||||
PasswordHash = string.Empty
|
|
||||||
};
|
|
||||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
|
||||||
|
|
||||||
db.Users.Add(user);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
var organization = new Organization
|
|
||||||
{
|
|
||||||
Name = organizationName,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
};
|
|
||||||
db.Organizations.Add(organization);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
db.OrganizationUsers.Add(new OrganizationUser
|
|
||||||
{
|
|
||||||
OrganizationId = organization.Id,
|
|
||||||
UserId = user.Id,
|
|
||||||
Role = OrganizationRole.Admin
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct);
|
|
||||||
|
|
||||||
var accessToken = tokenService.GenerateToken(user);
|
|
||||||
var refreshTokenValue = GenerateSecureToken();
|
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
|
||||||
{
|
|
||||||
Token = refreshTokenValue,
|
|
||||||
UserId = user.Id,
|
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<LoginResponse?> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var token = await db.RefreshTokens
|
|
||||||
.Include(rt => rt.User)
|
|
||||||
.ThenInclude(u => u.Organizations)
|
|
||||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct);
|
|
||||||
|
|
||||||
if (token is null || token.ExpiresAt < DateTimeOffset.UtcNow)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
token.IsRevoked = true;
|
|
||||||
|
|
||||||
var accessToken = tokenService.GenerateToken(token.User);
|
|
||||||
var newRefreshTokenValue = GenerateSecureToken();
|
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
|
||||||
{
|
|
||||||
Token = newRefreshTokenValue,
|
|
||||||
UserId = token.UserId,
|
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return new LoginResponse(accessToken.Token, newRefreshTokenValue, accessToken.ExpiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LogoutAsync(string refreshToken, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var token = await db.RefreshTokens
|
|
||||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct);
|
|
||||||
|
|
||||||
if (token is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
token.IsRevoked = true;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateSecureToken()
|
|
||||||
{
|
|
||||||
var bytes = RandomNumberGenerator.GetBytes(64);
|
|
||||||
return Convert.ToBase64String(bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public static class AuthorizationPolicies
|
|
||||||
{
|
|
||||||
public const string FlagsApiAccess = "FlagsApiAccess";
|
|
||||||
public const string AdminApiAccess = "AdminApiAccess";
|
|
||||||
public const string OrganizationAdmin = "OrganizationAdmin";
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using MicCheck.Api.Users;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public interface ITokenService
|
|
||||||
{
|
|
||||||
TokenResponse GenerateToken(User user);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public record LoginRequest(string Email, string Password);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public record LogoutRequest(string Token);
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public enum ProjectPermission
|
|
||||||
{
|
|
||||||
ViewProject,
|
|
||||||
CreateFeature,
|
|
||||||
EditFeature,
|
|
||||||
DeleteFeature,
|
|
||||||
CreateEnvironment,
|
|
||||||
EditEnvironment,
|
|
||||||
DeleteEnvironment,
|
|
||||||
CreateSegment,
|
|
||||||
EditSegment,
|
|
||||||
DeleteSegment,
|
|
||||||
ManageWebhooks,
|
|
||||||
ViewAuditLog
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public class ProjectPermissionRequirement : IAuthorizationRequirement
|
|
||||||
{
|
|
||||||
public ProjectPermission Permission { get; }
|
|
||||||
|
|
||||||
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using MicCheck.Api.Data;
|
|
||||||
using MicCheck.Api.Organizations;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public class ProjectPermissionRequirementHandler(
|
|
||||||
MicCheckDbContext db,
|
|
||||||
IHttpContextAccessor httpContextAccessor)
|
|
||||||
: AuthorizationHandler<ProjectPermissionRequirement>
|
|
||||||
{
|
|
||||||
protected override async Task HandleRequirementAsync(
|
|
||||||
AuthorizationHandlerContext context,
|
|
||||||
ProjectPermissionRequirement requirement)
|
|
||||||
{
|
|
||||||
var userIdClaim = context.User.FindFirst(JwtRegisteredClaimNames.Sub);
|
|
||||||
if (userIdClaim is null || !int.TryParse(userIdClaim.Value, out var userId))
|
|
||||||
{
|
|
||||||
context.Fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.User.HasClaim("OrganizationRole", OrganizationRole.Admin.ToString()))
|
|
||||||
{
|
|
||||||
context.Succeed(requirement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var httpContext = httpContextAccessor.HttpContext;
|
|
||||||
if (httpContext is null ||
|
|
||||||
!httpContext.Request.RouteValues.TryGetValue("projectId", out var projectIdValue) ||
|
|
||||||
!int.TryParse(projectIdValue?.ToString(), out var projectId))
|
|
||||||
{
|
|
||||||
context.Fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var permission = await db.UserProjectPermissions
|
|
||||||
.FirstOrDefaultAsync(p => p.UserId == userId && p.ProjectId == projectId);
|
|
||||||
|
|
||||||
if (permission is null)
|
|
||||||
{
|
|
||||||
context.Fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (permission.IsAdmin || permission.Permissions.Contains(requirement.Permission))
|
|
||||||
context.Succeed(requirement);
|
|
||||||
else
|
|
||||||
context.Fail();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public record RefreshRequest(string Token);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public record RegisterRequest(
|
|
||||||
string Email,
|
|
||||||
string Password,
|
|
||||||
string FirstName,
|
|
||||||
string LastName,
|
|
||||||
string OrganizationName);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public record TokenResponse(string Token, DateTime ExpiresAt);
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
|
||||||
using MicCheck.Api.Users;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
|
|
||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public class TokenService(IConfiguration configuration) : ITokenService
|
|
||||||
{
|
|
||||||
public TokenResponse GenerateToken(User user)
|
|
||||||
{
|
|
||||||
var secretKey = configuration["Jwt:SecretKey"]
|
|
||||||
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
|
|
||||||
var issuer = configuration["Jwt:Issuer"]
|
|
||||||
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
|
||||||
var audience = configuration["Jwt:Audience"]
|
|
||||||
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
|
||||||
var expiryMinutes = int.Parse(configuration["Jwt:ExpiryMinutes"] ?? "60");
|
|
||||||
|
|
||||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
|
||||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
|
||||||
var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes);
|
|
||||||
|
|
||||||
var claims = new List<Claim>
|
|
||||||
{
|
|
||||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
|
||||||
new(JwtRegisteredClaimNames.Email, user.Email),
|
|
||||||
new(JwtRegisteredClaimNames.GivenName, user.FirstName),
|
|
||||||
new(JwtRegisteredClaimNames.FamilyName, user.LastName),
|
|
||||||
new(JwtRegisteredClaimNames.Iat,
|
|
||||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
|
|
||||||
ClaimValueTypes.Integer64),
|
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var org in user.Organizations)
|
|
||||||
{
|
|
||||||
claims.Add(new Claim("OrganizationId", org.OrganizationId.ToString()));
|
|
||||||
claims.Add(new Claim("OrganizationRole", org.Role.ToString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
|
||||||
issuer: issuer,
|
|
||||||
audience: audience,
|
|
||||||
claims: claims,
|
|
||||||
expires: expiresAt,
|
|
||||||
signingCredentials: credentials);
|
|
||||||
|
|
||||||
return new TokenResponse(new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace MicCheck.Api.Authorization;
|
|
||||||
|
|
||||||
public class UserProjectPermission
|
|
||||||
{
|
|
||||||
public int UserId { get; init; }
|
|
||||||
public int ProjectId { get; init; }
|
|
||||||
public List<ProjectPermission> Permissions { get; set; } = [];
|
|
||||||
public bool IsAdmin { get; set; }
|
|
||||||
}
|
|
||||||
@@ -13,11 +13,12 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="2.6.0" />
|
<PackageReference Include="Scalar.AspNetCore" Version="2.6.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using MicCheck.Api.ApiKeys;
|
using MicCheck.Api.Common.Security.ApiKeys;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
namespace MicCheck.Api.Tests.Unit.ApiKeys;
|
namespace MicCheck.Api.Tests.Unit.ApiKeys;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using MicCheck.Api.ApiKeys;
|
using MicCheck.Api.Common.Security.ApiKeys;
|
||||||
|
|
||||||
namespace MicCheck.Api.Tests.Unit.ApiKeys;
|
namespace MicCheck.Api.Tests.Unit.ApiKeys;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using MicCheck.Api.ApiKeys;
|
using MicCheck.Api.Common.Security.ApiKeys;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using MicCheck.Api.Authorization;
|
using MicCheck.Api.Common.Security.Authorization;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
namespace MicCheck.Api.Tests.Unit.Authorization;
|
namespace MicCheck.Api.Tests.Unit.Authorization;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using MicCheck.Api.Authorization;
|
using MicCheck.Api.Common.Security.Authorization;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using MicCheck.Api.Authorization;
|
using MicCheck.Api.Common.Security.Authorization;
|
||||||
using MicCheck.Api.Organizations;
|
using MicCheck.Api.Organizations;
|
||||||
using MicCheck.Api.Users;
|
using MicCheck.Api.Users;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|||||||
Reference in New Issue
Block a user