50 lines
5.5 KiB
C#
50 lines
5.5 KiB
C#
using GuestOps.Web;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using System.Net.Mail;
|
|
|
|
public static class IdentitySecurityTests
|
|
{
|
|
sealed class FakeTransport(Func<int,Exception?> outcome):IAccountMailTransport
|
|
{
|
|
public int Calls;
|
|
public Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken){Calls++;var error=outcome(Calls);return error==null?Task.CompletedTask:Task.FromException(error);}
|
|
}
|
|
public static async Task Run(Action<string,bool> check,IStore store)
|
|
{
|
|
var secret=System.Text.Encoding.ASCII.GetBytes("12345678901234567890");
|
|
check("TOTP matches RFC 6238 SHA1 vector truncated to six digits",Totp.Code(secret,1)=="287082"&&Totp.Code(secret,37037036)=="081804");
|
|
var now=DateTimeOffset.FromUnixTimeSeconds(1_234_567_890).UtcDateTime;var step=Totp.Step(now);var current=Totp.Code(secret,step);
|
|
check("TOTP accepts a current code",Totp.Verify(secret,current,now,null)==step);
|
|
check("TOTP accepts the approved one-step clock window",Totp.Verify(secret,Totp.Code(secret,step-1),now,null)==step-1&&Totp.Verify(secret,Totp.Code(secret,step+1),now,null)==step+1);
|
|
check("TOTP rejects malformed and out-of-window codes",Totp.Verify(secret,"12345x",now,null)==null&&Totp.Verify(secret,Totp.Code(secret,step+2),now,null)==null);
|
|
check("TOTP rejects replayed time steps",Totp.Verify(secret,current,now,step)==null);
|
|
check("Base32 secret round-trips",Totp.Decode(Totp.Encode(secret)).SequenceEqual(secret));
|
|
check("Legacy Staff role has Agent permissions",Access.NormalizeRole("Staff")==Access.Agent&&Access.Permissions("Staff").Contains(Access.Inbox)&&!Access.Permissions("Staff").Contains(Access.Team));
|
|
check("Auditor cannot access guest workflows",Access.Permissions(Access.Auditor).Contains(Access.Audit)&&!Access.Permissions(Access.Auditor).Contains(Access.Inbox));
|
|
check("Managers cannot manage Managers",Access.CanManage(Access.Manager,Access.Agent)&&Access.CanManage(Access.Manager,Access.Auditor)&&!Access.CanManage(Access.Manager,Access.Manager));
|
|
|
|
var protection=new EphemeralDataProtectionProvider();var hotel=Guid.NewGuid().ToString("N");var user=new StaffUser{HotelId=hotel,Email=hotel+"@example.invalid",Name="MFA user",SecurityStamp="initial",Role=Access.Agent};await store.Insert(user);
|
|
var mfa=new MfaService(store,protection);var enrollment=await mfa.Begin(user);var enrollmentSecret=Totp.Decode(enrollment.Secret);user=(await store.Get<StaffUser>(hotel,user.Id))!;var code=Totp.Code(enrollmentSecret,Totp.Step(DateTime.UtcNow));
|
|
var attempts=await Task.WhenAll(Enumerable.Range(0,4).Select(async _=>{var copy=(await store.Get<StaffUser>(hotel,user.Id))!;return await mfa.Enable(copy,code);}));
|
|
check("Concurrent MFA enrollment verification succeeds once",attempts.Count(x=>x!=null)==1);
|
|
var codes=attempts.Single(x=>x!=null)!;user=(await store.Get<StaffUser>(hotel,user.Id))!;
|
|
check("MFA stores protected secret and hashed recovery codes",user.MfaSecretProtected.Length>0&&!user.MfaSecretProtected.Contains(enrollment.Secret)&&user.RecoveryCodeHashes.Length==10&&codes.All(x=>!user.RecoveryCodeHashes.Contains(x)));
|
|
var used=await mfa.UseRecovery(user,codes[0]);check("Recovery code is consumed once",used!=null&&await mfa.UseRecovery((await store.Get<StaffUser>(hotel,user.Id))!,codes[0])==null);
|
|
|
|
var mailConfig=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{{"Preview","false"}}).Build();var mail=new AccountMailService(store,protection,mailConfig);
|
|
var queued=await mail.Queue(user,"Test","Security notice","Safe body","https://example.invalid/private-token",DateTime.UtcNow.AddMinutes(5));
|
|
check("Account mail protects address body and token at rest",!queued.ProtectedPayload.Contains(user.Email)&&!queued.ProtectedPayload.Contains("private-token")&&queued.MessageId.StartsWith('<'));
|
|
var success=new FakeTransport(_=>null);await new AccountMailProcessor(store,mail,success,NullLogger<AccountMailProcessor>.Instance).Process(queued,CancellationToken.None);
|
|
check("Account mail completes one claimed delivery",(await store.Get<AccountMail>(hotel,queued.Id))?.State=="Sent"&&success.Calls==1);
|
|
var ambiguous=await mail.Queue(user,"Test","Notice","Body","",DateTime.UtcNow.AddMinutes(5));var uncertainTransport=new FakeTransport(_=>new SmtpException("ambiguous"));
|
|
await new AccountMailProcessor(store,mail,uncertainTransport,NullLogger<AccountMailProcessor>.Instance).Process(ambiguous,CancellationToken.None);
|
|
check("Ambiguous SMTP outcome is never automatically retried",(await store.Get<AccountMail>(hotel,ambiguous.Id))?.State=="NeedsReview");
|
|
var transient=await mail.Queue(user,"Test","Notice","Body","",DateTime.UtcNow.AddMinutes(5));var preSubmit=new FakeTransport(_=>new MailPreSubmissionException("not submitted"));
|
|
await new AccountMailProcessor(store,mail,preSubmit,NullLogger<AccountMailProcessor>.Instance).Process(transient,CancellationToken.None);var pending=await store.Get<AccountMail>(hotel,transient.Id);
|
|
check("Clearly pre-submission mail failure receives bounded retry",pending?.State=="Pending"&&pending.AttemptCount==1&&pending.NextAttemptAt>DateTime.UtcNow);
|
|
check("Preference filters are fixed presets",IdentityEndpoints.ValidFilter("DraftReady")&&!IdentityEndpoints.ValidFilter("OwnerOnly"));
|
|
}
|
|
}
|