using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Identity; namespace GuestOps.Web; public sealed record InviteInput(string Name,string Email); public sealed record AccountTokenInput(string Token); public sealed record AccountAcceptInput(string Token,string Password,string ConfirmPassword); public sealed record AccountLinkResult(string UserId,string Link,DateTime ExpiresAt); public sealed class AccountInvalid(string message):Exception(message); public sealed class AccountConflict(string message):Exception(message); public sealed class TeamAccounts(IStore store,IPasswordHasher hasher,IConfiguration config) { public static object View(StaffUser user)=>new {user.Id,user.Name,user.Email,user.Role,user.Active,user.Version,pending=user.PasswordHash.Length==0,linkPurpose=user.AccountLinkPurpose,linkExpiresAt=user.AccountLinkExpiresAt}; public static bool SessionValid(StaffUser user,string? stamp)=>user.Active&&user.SecurityStamp==(stamp??""); public static bool PasswordValid(string? password)=>password!=null&&password.Length>=14&&password.Length<=128; static string Hash(string token)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); string BaseUrl() { if(config.GetValue("Preview"))return "http://127.0.0.1:5173"; var value=config["PublicUrl"]; if(!Uri.TryCreate(value,UriKind.Absolute,out var uri)||uri.Scheme!="https"||uri.Port!=443||uri.IsLoopback||uri.UserInfo.Length>0||uri.AbsolutePath!="/"||uri.Query.Length>0||uri.Fragment.Length>0)throw new AccountInvalid("The administrator must configure the public HTTPS address before issuing account links."); return uri.GetLeftPart(UriPartial.Authority); } public async Task Invite(string hotel,InviteInput input) { if(!Input.Text(input.Name,2,100)||!Input.Text(input.Email,3,254))throw new AccountInvalid("Enter a staff name and email address."); var email=input.Email.Trim().ToLowerInvariant();if(!Input.Email(email)||email.Any(char.IsControl))throw new AccountInvalid("Enter one plain staff email address."); _=BaseUrl(); var user=await store.FindLogin(email); if(user!=null&&(user.HotelId!=hotel||user.Role!="Staff"||user.PasswordHash.Length>0))throw new AccountInvalid("This email is unavailable for invitation. Contact the administrator."); if(user==null) { if((await store.List(hotel)).Count>=50)throw new AccountInvalid("This hotel has reached the 50-account pilot limit. Contact the administrator."); user=new StaffUser{HotelId=hotel,Name=input.Name.Trim(),Email=email,Role="Staff",Active=false}; if(!await store.TryInsertStaff(user))throw new AccountConflict("The account changed elsewhere. Refresh the team list."); } user.Name=input.Name.Trim();return await Issue(user,"Invite",TimeSpan.FromHours(48)); } public Task ResetStaff(StaffUser user,long version) { if(user.Role!="Staff"||!user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only the current active staff account can receive a recovery link."); return Issue(user,"Reset",TimeSpan.FromMinutes(30)); } public Task RecoverOwner(StaffUser user) { if(user.Role!="Owner"||!user.Active)throw new AccountInvalid("An active owner account is required.");return Issue(user,"Reset",TimeSpan.FromMinutes(30)); } public Task Restore(StaffUser user,long version) { if(user.Role!="Staff"||user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only the current disabled staff account can be restored."); return Issue(user,"Restore",TimeSpan.FromHours(48)); } async Task Issue(StaffUser user,string purpose,TimeSpan lifetime) { var root=BaseUrl();var token=Convert.ToHexString(RandomNumberGenerator.GetBytes(32));var version=user.Version; user.AccountLinkHash=Hash(token);user.AccountLinkPurpose=purpose;user.AccountLinkExpiresAt=DateTime.UtcNow.Add(lifetime);user.Version++; if(!await store.Replace(user.HotelId,user.Id,version,user))throw new AccountConflict("The account changed elsewhere. Refresh and issue a new link."); return new(user.Id,root+"/account#token="+token,user.AccountLinkExpiresAt.Value); } public async Task Inspect(string? token) { if(token==null||!Regex.IsMatch(token,"^[A-F0-9]{64}$"))return null; var user=await store.FindAccountLink(Hash(token)); if(user==null||user.AccountLinkExpiresAt<=DateTime.UtcNow||user.AccountLinkExpiresAt==null)return null; if(user.AccountLinkPurpose=="Invite"&&user.Role=="Staff"&&!user.Active&&user.PasswordHash.Length==0)return user; if(user.AccountLinkPurpose=="Restore"&&user.Role=="Staff"&&!user.Active&&user.PasswordHash.Length>0)return user; return user.AccountLinkPurpose=="Reset"&&user.Active&&user.PasswordHash.Length>0?user:null; } public async Task Accept(AccountAcceptInput input) { if(!PasswordValid(input.Password)||input.Password!=input.ConfirmPassword)throw new AccountInvalid("Use matching passwords of 14 to 128 characters."); var user=await Inspect(input.Token);if(user==null)return false; var hash=user.AccountLinkHash;var version=user.Version; user.PasswordHash=hasher.HashPassword(user,input.Password);user.Active=true;user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++; user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null; return await store.ConsumeAccountLink(user,version,hash); } public async Task Disable(StaffUser user,long version) { if(user.Role!="Staff"||user.Version!=version)return false; user.Active=false;user.SecurityStamp=Guid.NewGuid().ToString("N");user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;user.Version++; return await store.Replace(user.HotelId,user.Id,version,user); } public async Task Revoke(StaffUser user,long version) { if(user.Role!="Staff"||user.Version!=version)return false; user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;user.Version++; return await store.Replace(user.HotelId,user.Id,version,user); } }