| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Query.Input; |
| | | 4 | | using ArturRios.Heimdall.Query.Output; |
| | | 5 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 6 | | using ArturRios.Mediator.Query.Interfaces; |
| | | 7 | | using ArturRios.Output; |
| | | 8 | | using Microsoft.EntityFrameworkCore; |
| | | 9 | | |
| | | 10 | | namespace ArturRios.Heimdall.Query.Handlers; |
| | | 11 | | |
| | | 12 | | /// <summary> |
| | | 13 | | /// Handles <see cref="GetTwoFactorStatusQuery" /> (FR-2F-15): reports the caller's own two-factor |
| | | 14 | | /// state — active or not, which methods are configured, and how many recovery codes remain. |
| | | 15 | | /// </summary> |
| | | 16 | | /// <remarks> |
| | | 17 | | /// <para> |
| | | 18 | | /// A caller who never enabled two-factor authentication is answered with every flag |
| | | 19 | | /// <c>false</c> and a zero count, not with a refusal. That is the ordinary state of most |
| | | 20 | | /// accounts, and a client's settings screen should not have to render its most common state |
| | | 21 | | /// out of an error branch. <c>NotActive</c>'s 404 stays what UC-39 and UC-40 use it for: |
| | | 22 | | /// refusing an operation that requires an active configuration. |
| | | 23 | | /// </para> |
| | | 24 | | /// <para> |
| | | 25 | | /// A caller who is not an eligible person is refused with <c>NotEligible</c> (403), exactly |
| | | 26 | | /// as UC-36's AF-36b refuses the same caller. <see cref="GoogleUser" /> and |
| | | 27 | | /// <see cref="Person" /> are separate tables with separate <c>PublicId</c> spaces, so a |
| | | 28 | | /// Google-issued token's subject never resolves here — and FR-2F-01 makes Google Users |
| | | 29 | | /// permanently ineligible, which an all-false success would misreport as "off, and you could |
| | | 30 | | /// turn it on". The same miss covers a token naming a person who no longer exists; |
| | | 31 | | /// <c>ActorLivenessFilter</c> already answers 401 for that case before a request arrives, so |
| | | 32 | | /// this branch is defence in depth. |
| | | 33 | | /// </para> |
| | | 34 | | /// </remarks> |
| | 10 | 35 | | public class GetTwoFactorStatusQueryHandler( |
| | 10 | 36 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 10 | 37 | | IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader, |
| | 10 | 38 | | IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader) |
| | | 39 | | : IQueryHandlerAsync<GetTwoFactorStatusQuery, TwoFactorStatusOutput> |
| | | 40 | | { |
| | | 41 | | public async Task<DataOutput<TwoFactorStatusOutput?>> HandleAsync(GetTwoFactorStatusQuery query, CancellationToken c |
| | | 42 | | { |
| | 10 | 43 | | var output = DataOutput<TwoFactorStatusOutput?>.New; |
| | | 44 | | |
| | | 45 | | // AF-36b: the caller must be a live person. A Google User misses this lookup entirely. |
| | 10 | 46 | | var personId = await personReader.Query() |
| | 10 | 47 | | .Where(person => person.PublicId == query.ActingPersonId && !person.IsDeleted) |
| | 10 | 48 | | .Select(person => (long?)person.Id) |
| | 10 | 49 | | .FirstOrDefaultAsync(); |
| | | 50 | | |
| | 10 | 51 | | if (personId is null) |
| | | 52 | | { |
| | 3 | 53 | | return output.WithError(TwoFactorMessages.NotEligible); |
| | | 54 | | } |
| | | 55 | | |
| | | 56 | | // Only these four fields are ever read below; projecting keeps the encrypted TOTP secret |
| | | 57 | | // out of process memory and off the change tracker entirely. |
| | 7 | 58 | | var configuration = await twoFactorReader.Query() |
| | 7 | 59 | | .Where(x => x.PersonId == personId.Value) |
| | 7 | 60 | | .Select(x => new |
| | 7 | 61 | | { |
| | 7 | 62 | | x.Id, |
| | 7 | 63 | | x.IsActive, |
| | 7 | 64 | | x.AppEnabled, |
| | 7 | 65 | | x.EmailEnabled |
| | 7 | 66 | | }) |
| | 7 | 67 | | .FirstOrDefaultAsync(); |
| | | 68 | | |
| | 7 | 69 | | if (configuration is null) |
| | | 70 | | { |
| | 2 | 71 | | return output |
| | 2 | 72 | | .WithData(new TwoFactorStatusOutput()) |
| | 2 | 73 | | .WithMessage(TwoFactorMessages.StatusRetrieved); |
| | | 74 | | } |
| | | 75 | | |
| | 5 | 76 | | var remainingRecoveryCodes = await recoveryCodeReader.Query() |
| | 5 | 77 | | .CountAsync(code => code.TwoFactorAuthId == configuration.Id && !code.Used); |
| | | 78 | | |
| | 5 | 79 | | return output |
| | 5 | 80 | | .WithData(new TwoFactorStatusOutput |
| | 5 | 81 | | { |
| | 5 | 82 | | IsActive = configuration.IsActive, |
| | 5 | 83 | | AppEnabled = configuration.AppEnabled, |
| | 5 | 84 | | EmailEnabled = configuration.EmailEnabled, |
| | 5 | 85 | | RemainingRecoveryCodes = remainingRecoveryCodes |
| | 5 | 86 | | }) |
| | 5 | 87 | | .WithMessage(TwoFactorMessages.StatusRetrieved); |
| | 10 | 88 | | } |
| | | 89 | | } |