feat: Implement API client and token models with hashing and JWT authentication

- Added ApiClientModel and ApiTokenModel for managing API clients and tokens.
- Introduced ConfigurationDefinitionModel and ConfigurationValueModel for configuration management.
- Created CredentialSecretModel for storing credential secrets.
- Developed DeploymentArtifactModel and DeploymentBatchModel for deployment management.
- Enhanced DeploymentTargetModel and DeploymentTemplateSelectionModel to support template revisions.
- Added TemplateRevisionModel and TemplateVersionModel for versioning templates.
- Implemented ApiClientSecretHasher for secure secret hashing.
- Created ApiTokenService for generating and validating JWT tokens.
- Updated QueueJobService to handle deployment requests with artifacts.
- Configured authentication settings in appsettings.json for JWT and Negotiate authentication.
This commit is contained in:
Torsten Brendgen
2026-07-09 14:44:38 +02:00
parent 1aa2adac08
commit dc93ea0813
72 changed files with 32906 additions and 516 deletions

View File

@@ -0,0 +1,221 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.SelfService.Portal.Core.API.Context;
using Microsoft.SelfService.Portal.Core.API.Dto.Auth;
using Microsoft.SelfService.Portal.Core.API.Models;
using Microsoft.SelfService.Portal.Core.API.Services;
using System.Security.Claims;
using System.Text.Json;
namespace Microsoft.SelfService.Portal.Core.API.Controllers
{
[Route("api/tokens")]
[ApiController]
public class ApiTokenController : Controller
{
private readonly DataContext _context;
private readonly ApiTokenService _tokenService;
public ApiTokenController(DataContext context, ApiTokenService tokenService)
{
_context = context;
_tokenService = tokenService;
}
[HttpGet("my")]
[Authorize(Policy = "TokenManage")]
[ProducesResponseType(200, Type = typeof(IEnumerable<GetManagedTokenDto>))]
public IActionResult GetMyTokens()
{
var subject = GetCurrentSubject();
var subjectType = GetCurrentSubjectType();
var tokens = _context.ApiTokens
.Include(token => token.ApiClient)
.Where(token => token.Subject == subject && token.SubjectType == subjectType)
.OrderByDescending(token => token.IssuedAt)
.ToList();
return Ok(tokens.Select(ToDto));
}
[HttpPost("my")]
[Authorize(Policy = "TokenManage")]
[ProducesResponseType(200, Type = typeof(CreateManagedTokenResponseDto))]
[ProducesResponseType(400)]
public IActionResult CreateMyToken([FromBody] CreateManagedTokenRequestDto request)
{
if (request == null || string.IsNullOrWhiteSpace(request.Scope))
{
return BadRequest(new { message = "Scope is required." });
}
IReadOnlyCollection<string> scopes;
try
{
scopes = _tokenService.ResolveRequestedUserScopes(request.Scope);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
var subject = GetCurrentSubject();
var subjectType = GetCurrentSubjectType();
var name = string.IsNullOrWhiteSpace(request.Name)
? $"Token for {subject}"
: request.Name.Trim();
var token = _tokenService.CreateAccessToken(
subject,
subjectType == "ApiClient" ? subject : null,
scopes);
var now = DateTime.UtcNow;
var expiresAt = request.ExpiresAt.HasValue && request.ExpiresAt.Value < token.ExpiresAt
? request.ExpiresAt.Value
: token.ExpiresAt;
if (expiresAt <= now)
{
return BadRequest(new { message = "ExpiresAt must be in the future." });
}
var model = new ApiTokenModel
{
Id = Guid.NewGuid(),
Jti = token.Jti,
Subject = subject,
SubjectType = subjectType,
Name = name,
ScopesJson = JsonSerializer.Serialize(scopes),
IssuedAt = token.IssuedAt,
ExpiresAt = expiresAt,
Created = now,
CreatedBy = subject,
Modified = now,
ModifiedBy = subject
};
_context.ApiTokens.Add(model);
_context.SaveChanges();
return Ok(new CreateManagedTokenResponseDto
{
Id = model.Id,
AccessToken = token.AccessToken,
ExpiresIn = Math.Max(0, Convert.ToInt32((expiresAt - now).TotalSeconds)),
ExpiresAt = expiresAt,
Scope = string.Join(' ', scopes)
});
}
[HttpDelete("my/{id}")]
[Authorize(Policy = "TokenManage")]
[ProducesResponseType(200, Type = typeof(GetManagedTokenDto))]
[ProducesResponseType(404)]
public IActionResult RevokeMyToken(Guid id)
{
var subject = GetCurrentSubject();
var subjectType = GetCurrentSubjectType();
var token = _context.ApiTokens.FirstOrDefault(existing =>
existing.Id == id
&& existing.Subject == subject
&& existing.SubjectType == subjectType);
if (token == null)
{
return NotFound();
}
RevokeToken(token, subject);
_context.SaveChanges();
return Ok(ToDto(token));
}
[HttpGet("admin")]
[Authorize(Policy = "TokenAdmin")]
[ProducesResponseType(200, Type = typeof(IEnumerable<GetManagedTokenDto>))]
public IActionResult GetAllTokens()
{
var tokens = _context.ApiTokens
.Include(token => token.ApiClient)
.OrderByDescending(token => token.IssuedAt)
.ToList();
return Ok(tokens.Select(ToDto));
}
[HttpDelete("admin/{id}")]
[Authorize(Policy = "TokenAdmin")]
[ProducesResponseType(200, Type = typeof(GetManagedTokenDto))]
[ProducesResponseType(404)]
public IActionResult RevokeTokenAsAdmin(Guid id)
{
var token = _context.ApiTokens.FirstOrDefault(existing => existing.Id == id);
if (token == null)
{
return NotFound();
}
RevokeToken(token, GetCurrentSubject());
_context.SaveChanges();
return Ok(ToDto(token));
}
[HttpGet("scopes")]
[Authorize(Policy = "TokenManage")]
[ProducesResponseType(200, Type = typeof(IEnumerable<string>))]
public IActionResult GetAllowedScopes()
{
return Ok(_tokenService.ReadAllowedTokenScopes());
}
private string GetCurrentSubject()
{
return User.FindFirstValue("client_id")
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "Unknown";
}
private string GetCurrentSubjectType()
{
return User.HasClaim(claim => claim.Type == "client_id") ? "ApiClient" : "User";
}
private static void RevokeToken(ApiTokenModel token, string revokedBy)
{
if (!token.RevokedAt.HasValue)
{
token.RevokedAt = DateTime.UtcNow;
token.RevokedBy = revokedBy;
token.Modified = DateTime.UtcNow;
token.ModifiedBy = revokedBy;
}
}
private static GetManagedTokenDto ToDto(ApiTokenModel token)
{
var scopes = JsonSerializer.Deserialize<string[]>(token.ScopesJson) ?? Array.Empty<string>();
return new GetManagedTokenDto
{
Id = token.Id,
Subject = token.Subject,
SubjectType = token.SubjectType,
ClientId = token.ApiClient?.ClientId,
Name = token.Name,
Scope = string.Join(' ', scopes),
IssuedAt = token.IssuedAt,
ExpiresAt = token.ExpiresAt,
RevokedAt = token.RevokedAt,
RevokedBy = token.RevokedBy,
LastUsedAt = token.LastUsedAt,
IsActive = !token.RevokedAt.HasValue && token.ExpiresAt > DateTime.UtcNow
};
}
}
}