Files
Microsoft.SelfService.Porta…/Controllers/ApiTokenController.cs
Torsten Brendgen dfd9c16f61 Add migration to update ApiClients with centralized authorization scopes
This migration updates the ScopesJson for the ApiClient with Id 91000000-0000-0000-0000-000000000001 to include additional authorization scopes for improved API access control. The Down method reverts the changes if necessary.
2026-07-09 23:49:01 +02:00

223 lines
7.6 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.SelfService.Portal.Core.API.Authorization;
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 = ApiPolicies.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 = ApiPolicies.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 = ApiPolicies.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 = ApiPolicies.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 = ApiPolicies.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 = ApiPolicies.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
};
}
}
}