Fix build errors and enable TreatWarningsAsErrors across all projects
Remove duplicate source files from old ApiKeys/, Authentication/, and Authorization/ folders that were already migrated to Common/Security/. Update test namespace usings to match. Pin EFC Relational 10.0.5 to resolve MSB3277 version conflict with Npgsql's transitive dependency. Add LangVersion and TreatWarningsAsErrors to AppHost and ServiceDefaults.
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UserSecretsId>d10a4485-2ac0-4ba7-bda5-8eb63e417567</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</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.OpenApi" 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">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</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="Serilog.AspNetCore" Version="9.0.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;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.ApiKeys;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using NUnit.Framework;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.ApiKeys;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Authorization;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
Reference in New Issue
Block a user