Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions SW.Bitween.Api/Data/BitweenDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.HasIndex(p => p.Hash).IsUnique();
});

modelBuilder.Entity<AccountLink>(b =>
{
b.ToTable("AccountLinks");
b.HasKey(p => p.Id);
b.Property(p => p.Id).ValueGeneratedOnAdd();
b.HasOne<Account>().WithMany().HasForeignKey(p => p.AccountId).OnDelete(DeleteBehavior.Cascade);

b.Property(p => p.Purpose).HasConversion<byte>();
b.Property(p => p.Hash).IsRequired().IsUnicode(false).HasMaxLength(64);
b.Property(p => p.EmailError).HasMaxLength(500);
// A link is looked up by its hash when it is opened.
b.HasIndex(p => p.Hash).IsUnique();
});

modelBuilder.Entity<Role>(b =>
{
b.ToTable("Roles");
Expand Down
77 changes: 77 additions & 0 deletions SW.Bitween.Api/Domain/Accounts/AccountLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System;
using System.Security.Cryptography;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain.Accounts;

/// <summary>
/// A link emailed to a person to choose their password: an invite to a new member, or a reset
/// asked for from the sign-in page. It works once, and only until it expires.
/// </summary>
/// <remarks>
/// Only a hash is kept, as for service tokens: the link itself is in the email, and whoever reads
/// this table can't use it. A new invite replaces the old one; reset links each last their hour.
/// Using any link ends every link the member has.
/// </remarks>
public class AccountLink : BaseEntity, IHasCreationTime
{
public static readonly TimeSpan InviteLifetime = TimeSpan.FromHours(48);
public static readonly TimeSpan ResetLifetime = TimeSpan.FromHours(1);

private AccountLink()
{
}

AccountLink(int accountId, AccountLinkPurpose purpose, string hash, DateTime expiresOn)
{
AccountId = accountId;
Purpose = purpose;
Hash = hash;
ExpiresOn = expiresOn;
}

/// <summary>A new link for the account, and the only time its token is known.</summary>
public static (AccountLink Link, string Token) Issue(int accountId, AccountLinkPurpose purpose, DateTime nowUtc)
{
// 32 random bytes, URL-safe: it goes in a link's query string.
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
var lifetime = purpose == AccountLinkPurpose.Invite ? InviteLifetime : ResetLifetime;
return (new AccountLink(accountId, purpose, HashOf(token), nowUtc + lifetime) { CreatedOn = nowUtc }, token);
}

public static string HashOf(string token) => ServiceToken.HashOf(token);

public int AccountId { get; private set; }
public AccountLinkPurpose Purpose { get; private set; }

/// <summary>SHA-256 of the token, hex.</summary>
public string Hash { get; private set; }

public DateTime ExpiresOn { get; private set; }

/// <summary>When the email carrying it was handed to the mail server; null when it wasn't sent.</summary>
public DateTime? EmailedOn { get; private set; }

/// <summary>Why the email couldn't be sent, for the member's page; null once one was.</summary>
public string EmailError { get; private set; }

public DateTime CreatedOn { get; set; }

public bool IsUsable(DateTime nowUtc) => ExpiresOn > nowUtc;

public void Emailed(DateTime nowUtc)
{
EmailedOn = nowUtc;
EmailError = null;
}

public void NotEmailed(string error) =>
EmailError = error is { Length: > 500 } ? error[..500] : error;
}

public enum AccountLinkPurpose : byte
{
Invite = 1,
PasswordReset = 2,
}
6 changes: 5 additions & 1 deletion SW.Bitween.Api/Resources/Accounts/ChangePassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace SW.Bitween.Resources.Accounts;

[HandlerName("changePassword")]
public class ChangePassword(BitweenDbContext dbContext, RequestContext requestContext, SignInThrottle throttle,
Microsoft.AspNetCore.Http.IHttpContextAccessor httpContextAccessor)
Microsoft.AspNetCore.Http.IHttpContextAccessor httpContextAccessor, Services.AccountMail mail)
: ICommandHandler<ChangePasswordModel, object>
{
public async Task<object> Handle(ChangePasswordModel request)
Expand Down Expand Up @@ -54,6 +54,10 @@ await dbContext.Set<RefreshToken>()
.Where(t => t.AccountId == account.Id)
.ExecuteDeleteAsync();

// Whoever holds a stolen session can change the password too; the owner hears of it.
mail.SendInBackground(account.Email, Services.AccountEmails.PasswordChanged(
Services.AccountEmails.ChangedBy.Themselves, account.Email, mail.SignedInBase, mail.ResetAvailable));

return null;
}

Expand Down
13 changes: 10 additions & 3 deletions SW.Bitween.Api/Resources/Accounts/Create.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ public async Task<object> Handle(CreateAccountModel request)
await requestContext.EnsurePermission(dbContext, Model.Permissions.Users.Create);

if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.Email) ||
(!_bitweenOptions.DisableEmailPasswordLogin && string.IsNullOrEmpty(request.Password)))
(!_bitweenOptions.DisableEmailPasswordLogin && !request.Invite && string.IsNullOrEmpty(request.Password)))
throw new SWValidationException("INVALID_PAYLOAD", "The payload is invalid");

// An invited member chooses their own password; one set here too would be a second
// way in that nobody asked them about.
if (request.Invite && !string.IsNullOrEmpty(request.Password))
throw new SWValidationException("INVITE_OR_PASSWORD",
"Send either a password or an invite, not both.");

if (await dbContext.Set<Account>().AnyAsync(a => a.Email == request.Email))
throw new SWValidationException("ACCOUNT_EXISTS", $"Account with email {request.Email} exists");

Expand All @@ -39,7 +45,7 @@ public async Task<object> Handle(CreateAccountModel request)

// No password at all when this instance signs in through Microsoft only - the account
// exists purely to be matched by email.
var password = _bitweenOptions.DisableEmailPasswordLogin
var password = _bitweenOptions.DisableEmailPasswordLogin || request.Invite
? null
: SecurePasswordHasher.Hash(request.Password);

Expand Down Expand Up @@ -70,7 +76,8 @@ public Validate(BitweenOptions bitweenOptions)
{
RuleFor(i => i.Name).NotEmpty();
RuleFor(i => i.Email).NotEmpty();
RuleFor(i => i.Password).Password().When(_ => !bitweenOptions.DisableEmailPasswordLogin);
// An invited member chooses their own password later, against the same rules.
RuleFor(i => i.Password).Password().When(i => !bitweenOptions.DisableEmailPasswordLogin && !i.Invite);
// The handler prefers explicit role ids and only falls back to the legacy coarse
// Role, so demanding Role unconditionally rejected the payload the UI actually
// sends - adding a member failed outright. Still required when no ids were sent,
Expand Down
82 changes: 82 additions & 0 deletions SW.Bitween.Api/Resources/Accounts/ForgotPassword.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SW.Bitween.Domain.Accounts;
using SW.Bitween.Model;
using SW.Bitween.Services;
using SW.PrimitiveTypes;

namespace SW.Bitween.Resources.Accounts;

/// <summary>
/// "Forgot password?": emails a reset link to the address, when it is a member's.
/// </summary>
/// <remarks>
/// The answer is the same whether the address has an account or not, and so is the work done
/// before it: one lookup. Making the link and sending it happen after the answer, so neither the
/// reply nor its timing says who has an account here. A member gets at most one email a minute,
/// however often anyone sends the form.
/// </remarks>
[HandlerName("forgotPassword")]
[Unprotect]
public class ForgotPassword(BitweenDbContext dbContext, AccountMail mail, IServiceScopeFactory scopes,
ILogger<ForgotPassword> logger) : ICommandHandler<ForgotPasswordModel, object>
{
static readonly TimeSpan OneEmailPer = TimeSpan.FromMinutes(1);

public async Task<object> Handle(ForgotPasswordModel request)
{
if (!mail.ResetAvailable)
throw new SWValidationException("PASSWORD_RESET_OFF",
"This Bitween can't email password resets. Ask your administrator to set a new password for you.");

var email = request.Email?.Trim();
if (string.IsNullOrEmpty(email)) return null;

var account = await dbContext.Set<Account>().AsNoTracking()
.Where(a => a.Email.ToLower() == email.ToLower() && a.Kind == AccountKind.Person && !a.Disabled)
.Select(a => new { a.Id, a.Email })
.FirstOrDefaultAsync();

if (account is not null)
_ = Task.Run(() => IssueAsync(account.Id, account.Email));

return null;
}

/// <summary>Makes the link and emails it, on a scope of its own: the request's is gone by then.</summary>
async Task IssueAsync(int accountId, string email)
{
try
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<BitweenDbContext>();
var now = DateTime.UtcNow;

if (await db.Set<AccountLink>().AnyAsync(l => l.AccountId == accountId &&
l.Purpose == AccountLinkPurpose.PasswordReset &&
l.CreatedOn > now - OneEmailPer))
return;

// Earlier reset links keep working until they expire, and using any of them ends them all.
// Replacing them here instead let two requests at once each delete the other's link, so
// one of the two emails carried a link that no longer worked. Only expired ones go.
await db.Set<AccountLink>()
.Where(l => l.AccountId == accountId && l.ExpiresOn <= now)
.ExecuteDeleteAsync();

var (link, token) = AccountLink.Issue(accountId, AccountLinkPurpose.PasswordReset, now);
db.Add(link);
await db.SaveChangesAsync();

await mail.SendAsync(email, AccountEmails.Reset(email, mail.PublicBase, token));
}
catch (Exception ex)
{
logger.LogWarning(ex, "A password reset for account {AccountId} could not be made.", accountId);
}
}
}
95 changes: 95 additions & 0 deletions SW.Bitween.Api/Resources/Accounts/Invite.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using SW.Bitween.Domain.Accounts;
using SW.Bitween.Model;
using SW.Bitween.Services;
using SW.PrimitiveTypes;

namespace SW.Bitween.Resources.Accounts;

/// <summary>
/// Invites a member who has no password yet to choose one: emailed when there is a mail server,
/// otherwise handed back for whoever invited to pass on. Sending it again replaces the last one.
/// </summary>
/// <remarks>
/// On an instance that signs in only with Microsoft or single sign-on there is no password to
/// choose, so the invite is an email saying where to sign in, and needs a mail server.
/// </remarks>
[HandlerName("invite")]
public class Invite(BitweenDbContext dbContext, RequestContext requestContext, BitweenOptions options,
AccountMail mail) : ICommandHandler<int, InviteAccountModel, object>
{
public async Task<object> Handle(int key, InviteAccountModel request)
{
await requestContext.EnsurePermission(dbContext, Model.Permissions.Users.Create);

var account = await Members.FindAsync(dbContext, key);
if (account is null)
throw new SWValidationException("ACCOUNT_NOT_FOUND", $"No account exists with the id {key}");

// The link chooses their password, which is signing in as them: as with Set password, only
// someone who holds everything they hold may have it.
await GrantLimits.EnsureCallerOutranks(dbContext, requestContext, key);

if (account.Disabled)
throw new SWValidationException("ACCOUNT_DISABLED",
$"{account.DisplayName} is disabled. Re-enable them before inviting them.");

var callerId = Convert.ToInt32(requestContext.GetNameIdentifier());
var inviter = await dbContext.Set<Account>().Where(a => a.Id == callerId)
.Select(a => a.DisplayName).FirstOrDefaultAsync() ?? "An administrator";
var linkBase = mail.SignedInBase;

if (options.DisableEmailPasswordLogin)
{
if (!mail.CanSend)
throw new SWValidationException("NO_MAIL_SERVER",
"Bitween can't email an invite until a mail server is set up in Settings, under Mail server.");

var sent = await mail.SendAsync(account.Email, AccountEmails.InviteToSignIn(inviter, account.Email, linkBase));
return new InviteResultModel { Emailed = sent is null, EmailError = sent };
}

if (!string.IsNullOrEmpty(account.Password))
throw new SWValidationException("ALREADY_HAS_PASSWORD",
$"{account.DisplayName} already has a password. Set a new one for them, " +
"or have them use Forgot password on the sign-in page.");

var now = DateTime.UtcNow;
var (link, token) = AccountLink.Issue(key, AccountLinkPurpose.Invite, now);

// Only the newest invite works, so a resend makes any earlier email useless. The old link goes
// and the new one is stored as one change, committed before anything is sent: an email is
// never sent for a link that wasn't saved, and a failed save leaves the old link working.
await using (var transaction = await dbContext.Database.BeginTransactionAsync())
{
await dbContext.Set<AccountLink>()
.Where(l => l.AccountId == key && l.Purpose == AccountLinkPurpose.Invite)
.ExecuteDeleteAsync();
dbContext.Add(link);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}

string error = null;
if (mail.CanSend)
{
error = await mail.SendAsync(account.Email, AccountEmails.Invite(inviter, account.Email, linkBase, token));
if (error is null) link.Emailed(now);
else link.NotEmailed(error);
await dbContext.SaveChangesAsync();
}

var emailed = mail.CanSend && error is null;
return new InviteResultModel
{
Emailed = emailed,
EmailError = error,
ExpiresOn = link.ExpiresOn,
// Handed back only when nobody else will get it to them.
Link = emailed ? null : $"{linkBase}/set-password?token={token}"
};
}
}
8 changes: 7 additions & 1 deletion SW.Bitween.Api/Resources/Accounts/Login.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace SW.Bitween.Resources.Accounts
[Unprotect]
public class Login(JwtTokenParameters jwtTokenParameters, BitweenDbContext dbContext,
BitweenOptions BitweenSettings, IHttpContextAccessor httpContextAccessor, ILogger<Login> logger,
SignInThrottle throttle, Services.OidcSignIn oidc) : ICommandHandler<UserLogin, object>
SignInThrottle throttle, Services.OidcSignIn oidc, Services.AccountMail mail) : ICommandHandler<UserLogin, object>
{
// Account-wide, across every address: the backstop for guessing spread over many of them.
// Five from any one address already locks that address out - see SignInThrottle.
Expand Down Expand Up @@ -297,6 +297,12 @@ await dbContext.Set<Account>()
a => a.FailedLoginCount + 1 >= MaxFailedLoginAttempts ? lockoutEnd : a.LockoutEnd)
.SetProperty(a => a.FailedLoginCount,
a => a.FailedLoginCount + 1 >= MaxFailedLoginAttempts ? 0 : a.FailedLoginCount + 1));

// It wasn't locked a moment ago - that was checked above - so a lock now is this
// attempt's, and the owner should know someone is guessing.
if (await dbContext.Set<Account>().AnyAsync(a => a.Id == account.Id && a.LockoutEnd > nowUtc))
mail.SendInBackground(account.Email, Services.AccountEmails.Locked(
account.Email, mail.PublicBase, MaxFailedLoginAttempts, lockoutEnd));
throw new SWException("Invalid username or password.");
}

Expand Down
Loading
Loading