| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Command.Services; |
| | | 5 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 7 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 8 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using FluentValidation; |
| | | 11 | | using Microsoft.EntityFrameworkCore; |
| | | 12 | | |
| | | 13 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Handles <see cref="IssueApplicationTokenCommand" /> (UC-56, FR-AP-13/14): finds the live |
| | | 17 | | /// credential by the hash of the presented secret, checks it belongs to the named application in |
| | | 18 | | /// the named scope, both live, and issues a token. |
| | | 19 | | /// </summary> |
| | | 20 | | /// <remarks> |
| | | 21 | | /// <para> |
| | | 22 | | /// <b>One refusal.</b> An unknown secret, a revoked or expired one, another application's, a |
| | | 23 | | /// deleted application or scope, and the wrong scope all answer |
| | | 24 | | /// <see cref="ApplicationTokenMessages.CredentialsNotAccepted" />, so the endpoint says |
| | | 25 | | /// nothing about which applications, scopes or credentials exist (the stance UC-11 takes for |
| | | 26 | | /// persons). The audit entry of a refusal therefore has no target. |
| | | 27 | | /// </para> |
| | | 28 | | /// <para> |
| | | 29 | | /// <b>Whitespace.</b> A secret has none, so it is trimmed: one read from a file arrives with |
| | | 30 | | /// its trailing newline, and refusing it would be a failure nobody could diagnose from one |
| | | 31 | | /// uniform 401. |
| | | 32 | | /// </para> |
| | | 33 | | /// <para> |
| | | 34 | | /// <b>Stamped before signing.</b> <c>LastUsedAt</c> is written as one conditional column |
| | | 35 | | /// (<see cref="IAtomicWrites.TryStampCredentialUseAsync" />) that only lands while the |
| | | 36 | | /// credential is still live, and the token is signed only after it has. A revocation (UC-55) |
| | | 37 | | /// that commits between the read and the stamp therefore refuses this exchange, rather than |
| | | 38 | | /// being written back over by a whole-row save of the credential as it was read. |
| | | 39 | | /// </para> |
| | | 40 | | /// </remarks> |
| | 33 | 41 | | public class IssueApplicationTokenCommandHandler( |
| | 33 | 42 | | IValidator<IssueApplicationTokenCommand> validator, |
| | 33 | 43 | | IAsyncReadOnlyRepository<ApplicationCredential, long> credentialReader, |
| | 33 | 44 | | IAtomicWrites atomicWrites, |
| | 33 | 45 | | IApplicationTokenIssuer issuer) |
| | | 46 | | : ICommandHandlerAsync<IssueApplicationTokenCommand, IssueApplicationTokenCommandOutput> |
| | | 47 | | { |
| | | 48 | | public async Task<DataOutput<IssueApplicationTokenCommandOutput?>> HandleAsync( |
| | | 49 | | IssueApplicationTokenCommand command, CancellationToken cancellationToken = default) |
| | | 50 | | { |
| | 33 | 51 | | var output = DataOutput<IssueApplicationTokenCommandOutput?>.New; |
| | | 52 | | |
| | | 53 | | // AF-56a. |
| | 33 | 54 | | var validation = await validator.ValidateAsync(command, cancellationToken); |
| | | 55 | | |
| | 33 | 56 | | if (!validation.IsValid) |
| | | 57 | | { |
| | 4 | 58 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 59 | | } |
| | | 60 | | |
| | 31 | 61 | | var now = DateTime.UtcNow; |
| | 31 | 62 | | var hash = ApplicationCredentialSecret.HashOf(command.Secret.Trim()); |
| | | 63 | | |
| | 31 | 64 | | var credential = await credentialReader.Query() |
| | 31 | 65 | | .Include(x => x.Application) |
| | 31 | 66 | | .ThenInclude(application => application.Scope) |
| | 31 | 67 | | .Where(ApplicationCredential.LiveAt(now)) |
| | 31 | 68 | | .FirstOrDefaultAsync(x => x.SecretHash == hash, cancellationToken); |
| | | 69 | | |
| | | 70 | | // AF-56b — every check, one answer. |
| | 31 | 71 | | if (credential is null || |
| | 31 | 72 | | credential.Application.PublicId != command.ApplicationId || |
| | 31 | 73 | | credential.Application.IsDeleted || |
| | 31 | 74 | | credential.Application.Scope.PublicId != command.ScopeId || |
| | 31 | 75 | | credential.Application.Scope.IsDeleted) |
| | | 76 | | { |
| | 16 | 77 | | return output.WithError(ApplicationTokenMessages.CredentialsNotAccepted); |
| | | 78 | | } |
| | | 79 | | |
| | | 80 | | // AF-56b again: revoked or expired since the read above. |
| | 15 | 81 | | if (!await atomicWrites.TryStampCredentialUseAsync(credential, now)) |
| | | 82 | | { |
| | 2 | 83 | | return output.WithError(ApplicationTokenMessages.CredentialsNotAccepted); |
| | | 84 | | } |
| | | 85 | | |
| | 13 | 86 | | var application = credential.Application; |
| | 13 | 87 | | var token = await issuer.IssueAsync(new ApplicationTokenSubject( |
| | 13 | 88 | | application.PublicId, application.Scope.PublicId, credential.PublicId, application.Name)); |
| | | 89 | | |
| | 13 | 90 | | return output |
| | 13 | 91 | | .WithData(new IssueApplicationTokenCommandOutput |
| | 13 | 92 | | { |
| | 13 | 93 | | Id = application.PublicId, |
| | 13 | 94 | | Token = token.Token, |
| | 13 | 95 | | ExpiresAt = token.ExpiresAt |
| | 13 | 96 | | }) |
| | 13 | 97 | | .WithMessage(ApplicationTokenMessages.TokenIssued); |
| | 33 | 98 | | } |
| | | 99 | | } |