92 lines
6.4 KiB
C#
92 lines
6.4 KiB
C#
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<StaffUser> 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<bool>("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<AccountLinkResult> 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<StaffUser>(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<AccountLinkResult> 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<AccountLinkResult> 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<AccountLinkResult> 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<AccountLinkResult> 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<StaffUser?> 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<bool> 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<bool> 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<bool> 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);
|
|
}
|
|
}
|