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
8 changes: 7 additions & 1 deletion .github/workflows/bitween-api-cicd-gateway.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,10 @@ jobs:
dependabot-alerts-token: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}

# App configuration passed to helm --set-string during deploy (masked)
helm-set-secret-values: 'db=${{ secrets.dbConnection }},global.bus.rabbitUrl=${{ secrets.rabbitUrl }},global.cloudFiles.secretAccessKey=${{ secrets.SecretAccessKey }},global.cloudFiles.accessKeyId=${{ secrets.AccessKeyId }},global.cloudFiles.serviceUrl=${{ secrets.ServiceUrl }},global.cloudFiles.bucketName=${{ secrets.BucketName }},environmentVariables.Bitween__RabbitMqManagementUrl=${{ secrets.RabbitMqManagementUrl }},environmentVariables.Bitween__RabbitMqManagementPassword=${{ secrets.RabbitMqManagementPassword }},environmentVariables.Bitween__RabbitMqManagementUsername=${{ secrets.RabbitMqManagementUsername }}'
#
# global.token.key signs every authentication token. The chart still carries a sample
# key as its default, and that value is published in this public repository — anyone
# holding it can mint a token for any identity, so the app refuses to start on it.
# Set the TokenKey secret to a long random string unique to this environment
# (openssl rand -base64 48). Changing it signs out everyone holding an older token.
helm-set-secret-values: 'global.token.key=${{ secrets.TokenKey }},db=${{ secrets.dbConnection }},global.bus.rabbitUrl=${{ secrets.rabbitUrl }},global.cloudFiles.secretAccessKey=${{ secrets.SecretAccessKey }},global.cloudFiles.accessKeyId=${{ secrets.AccessKeyId }},global.cloudFiles.serviceUrl=${{ secrets.ServiceUrl }},global.cloudFiles.bucketName=${{ secrets.BucketName }},environmentVariables.Bitween__RabbitMqManagementUrl=${{ secrets.RabbitMqManagementUrl }},environmentVariables.Bitween__RabbitMqManagementPassword=${{ secrets.RabbitMqManagementPassword }},environmentVariables.Bitween__RabbitMqManagementUsername=${{ secrets.RabbitMqManagementUsername }}'
6 changes: 5 additions & 1 deletion SW.Bitween.Api/Data/BitweenDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
Password = defaultPasswordHash,
Deleted = false,
Role = AccountRole.Admin,
FailedLoginCount = 0
FailedLoginCount = 0,
// True for a fresh installation, whose password is the published default.
// Installations that already exist are handled by the migration, which
// flags only those still holding that same value.
MustChangePassword = true
});
});

Expand Down
15 changes: 15 additions & 0 deletions SW.Bitween.Api/Domain/Accounts/Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ public Account(string displayName, string email, string password, AccountRole ro
public int FailedLoginCount { get; private set; }
public DateTime? LockoutEnd { get; private set; }

/// <summary>
/// Set while the account still has a password nobody chose. Signing in works, but the token
/// it returns grants nothing until the password is replaced.
/// </summary>
/// <remarks>
/// This exists for one account: the administrator seeded into every installation, whose
/// password ships in our public repository. On installations where it was never changed,
/// that published value was full administrative access to anyone who read the repository.
/// A migration sets this only where the stored password is still that one, so an
/// installation that changed it years ago notices nothing.
/// </remarks>
public bool MustChangePassword { get; private set; }

public bool IsLockedOut(DateTime nowUtc) => LockoutEnd.HasValue && LockoutEnd.Value > nowUtc;

public void RegisterSuccessfulLogin()
Expand Down Expand Up @@ -71,6 +84,8 @@ private bool AddLoginMethod(LoginMethod loginMethod)
public void SetPassword(string password)
{
Password = SecurePasswordHasher.Hash(password);
// Whoever set this one chose it, which is the whole requirement.
MustChangePassword = false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}


Expand Down
6 changes: 6 additions & 0 deletions SW.Bitween.Api/Extensions/AccountExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ private static ClaimsIdentity CreateClaimsIdentity(this Account account, LoginMe

if (account.Email != null) claims.Add(new Claim(ClaimTypes.Email, account.Email));

// Carried on the token rather than read per request like permissions are: it decides
// what the token itself is worth, and a token has to keep meaning the same thing for
// as long as it is valid.
if (account.MustChangePassword)
claims.Add(new Claim(RequestContextExtensions.MustChangePasswordClaim, "true"));


return new ClaimsIdentity(claims, "Bitween");
}
Expand Down
34 changes: 30 additions & 4 deletions SW.Bitween.Api/Extensions/RequestContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,34 @@ namespace SW.Bitween
public static class RequestContextExtensions
{
/// <summary>
/// Marks the break-glass token minted by POST /login from configured AdminCredentials. That
/// token has no account behind it, so its grants can't be resolved from the database. It
/// used to clear every check only because the old role guard failed open on a missing
/// claim; this claim makes the same grant deliberate instead of accidental.
/// Grants everything to an identity with no account behind it, so there are no roles to
/// resolve from the database. Only the integration test fixture mints it.
/// </summary>
/// <remarks>
/// It used to mark the token from <c>POST /login</c>, which signed in against a username
/// and password held in configuration. That defaulted to a working pair published in our
/// public repository, neither UI ever called it, and a penetration test used it to take
/// full control of a deployment. The endpoint is gone.
/// <para>
/// The claim stays because it is how a caller with no account is granted anything at all.
/// Minting one needs the signing key, which is enough to impersonate anybody anyway.
/// </para>
/// </remarks>
public const string SuperuserClaim = "bitween_superuser";

/// <summary>
/// Present on a token issued to an account whose password nobody has chosen. Such a token
/// authenticates but grants nothing, so the account can reach self-service — changing the
/// password — and nothing else.
/// </summary>
/// <remarks>
/// A sign-in has to succeed for the password to be changeable at all: the change requires
/// the current password and the caller's own identity, so refusing the sign-in outright
/// would leave the account with no way out but an administrator who may not exist. Granting
/// nothing is the same thing said in the only place that can act on it.
/// </remarks>
public const string MustChangePasswordClaim = "bitween_must_change_password";

/// <summary>
/// Throws unless the caller holds at least one of <paramref name="anyOf"/>. This is really a
/// "forbidden" — the caller is signed in and simply isn't allowed — but CqApi renders
Expand Down Expand Up @@ -49,6 +70,11 @@ public static async Task<bool> HasPermission(this RequestContext requestContext,
public static async Task<HashSet<string>> GetPermissions(this RequestContext requestContext,
BitweenDbContext dbContext)
{
// Checked ahead of everything, superuser included: a password nobody chose is not a
// basis for any grant, whatever else the token claims.
if (requestContext.User?.FindFirst(MustChangePasswordClaim) is not null)
return [];

if (requestContext.User?.FindFirst(SuperuserClaim) is not null)
return PermissionCatalog.AllKeys.ToHashSet();

Expand Down
9 changes: 9 additions & 0 deletions SW.Bitween.Api/Resources/Accounts/ChangePassword.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -28,6 +29,14 @@ public async Task<object> Handle(ChangePasswordModel request)
account.SetPassword(request.NewPassword);
await dbContext.SaveChangesAsync();

// Signing in with a refresh token skips password verification entirely — it looks the
// account up by id and issues a fresh token from whatever state it is now in. So a session
// opened with the old password outlives the change unless the tokens go with it, and
// "change the password" would not actually remove whoever you changed it because of.
await dbContext.Set<RefreshToken>()
.Where(t => t.AccountId == account.Id)
.ExecuteDeleteAsync();

return null;
}

Expand Down
10 changes: 8 additions & 2 deletions SW.Bitween.Api/Resources/Accounts/Login.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,14 @@ await dbContext.Set<Account>()
Expires = DateTimeOffset.UtcNow.AddDays(30)
});

// Return only the JWT — refresh token stays in the cookie, not in the response body
return new { Jwt = account.CreateJwt(LoginMethod.EmailAndPassword, jwtTokenParameters, jwtExpiryTimeSpan) };
// Return only the JWT — refresh token stays in the cookie, not in the response body.
// MustChangePassword rides along so the client can send them straight to the change
// form; the token grants nothing until they do, so this is a courtesy, not the control.
return new
{
Jwt = account.CreateJwt(LoginMethod.EmailAndPassword, jwtTokenParameters, jwtExpiryTimeSpan),
account.MustChangePassword
};
}

private string CreateRefreshToken(Account account, LoginMethod loginMethod)
Expand Down
3 changes: 2 additions & 1 deletion SW.Bitween.Api/Resources/Accounts/Profile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public async Task<object> Handle()
Name = a.DisplayName,
Id = a.Id,
Disabled = a.Disabled,
Role = a.Role.ToString()
Role = a.Role.ToString(),
MustChangePassword = a.MustChangePassword
})
.SingleOrDefaultAsync();

Expand Down
10 changes: 10 additions & 0 deletions SW.Bitween.Api/Resources/Accounts/SetPassword.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using SW.Bitween.Domain.Accounts;
using SW.Bitween.Model;
using SW.PrimitiveTypes;
Expand Down Expand Up @@ -31,6 +33,14 @@ public async Task<object> Handle(int key, SetAccountPasswordModel request)
account.SetPassword(request.Password);
await dbContext.SaveChangesAsync();

// Signing in with a refresh token skips password verification entirely — it looks the
// account up by id and issues a fresh token from whatever state it is now in. So a session
// opened with the old password outlives the change unless the tokens go with it, and
// "change the password" would not actually remove whoever you changed it because of.
await dbContext.Set<RefreshToken>()
.Where(t => t.AccountId == account.Id)
.ExecuteDeleteAsync();

return null;
}

Expand Down
46 changes: 0 additions & 46 deletions SW.Bitween.Api/Resources/Login/Login.cs

This file was deleted.

49 changes: 48 additions & 1 deletion SW.Bitween.Api/Resources/Settings/Config.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using SW.Bitween.Services;
using SW.PrimitiveTypes;
Expand All @@ -8,8 +11,30 @@ namespace SW.Bitween.Resources.Settings;
[HandlerName("Config")]
public class Config(BitweenOptions BitweenOptions, ThemeOptions themeOptions) : IQueryHandler<object>
{
/// <summary>
/// Brand links that name the vendor rather than the deployment, and so are withheld while they
/// still hold the value we shipped.
/// </summary>
/// <remarks>
/// This endpoint answers before anyone signs in, because the sign-in page brands itself from
/// it. A penetration test followed the default <c>GithubLink</c> from here to our public
/// repository, and from there to the sample signing key and the default administrator password
/// — the whole chain started with a link nobody had configured.
/// <para>
/// Withheld only at the default: a deployment that sets its own link is publishing its own
/// address and gets it back untouched. So the footer keeps working wherever it was meant to,
/// and stops pointing strangers at our source everywhere else.
/// </para>
/// </remarks>
private static readonly string[] VendorLinks = ["GithubLink"];

public async Task<object> Handle()
{
var defaults = new ThemeOptions();
var withheld = VendorLinks
.Where(name => Equals(Read(themeOptions, name), Read(defaults, name)))
.ToHashSet(StringComparer.Ordinal);

return new
{
BitweenOptions.MsalClientId,
Expand All @@ -19,10 +44,32 @@ public async Task<object> Handle()
IsRabbitMqManagementConfigured = !string.IsNullOrWhiteSpace(BitweenOptions.RabbitMqManagementUrl)
&& !string.IsNullOrWhiteSpace(BitweenOptions.RabbitMqManagementUsername)
&& !string.IsNullOrWhiteSpace(BitweenOptions.RabbitMqManagementPassword),
Theme = themeOptions,
Theme = ThemeWithout(withheld),
// The product defaults, so the sign-in page — which has no session and can't read the
// settings list — can tell a brand value someone chose from one nobody has touched.
ThemeDefaults = SettingsService.DefaultsUnder("Theme.")
.Where(kv => !withheld.Contains(Pascalize(kv.Key)))
.ToDictionary(kv => kv.Key, kv => kv.Value)
};
}

/// <summary>
/// The theme as a dictionary so a key can be left out of it.
/// </summary>
/// <remarks>
/// Built by reflection rather than written out property by property: a hand-written projection
/// silently drops whatever is added to <see cref="ThemeOptions"/> next, and the symptom — one
/// brand value that will not apply — looks nothing like its cause.
/// </remarks>
private Dictionary<string, object?> ThemeWithout(IReadOnlySet<string> withheld) =>
typeof(ThemeOptions).GetProperties()
.Where(p => p.CanRead && !withheld.Contains(p.Name))
.ToDictionary(p => Camelize(p.Name), p => p.GetValue(themeOptions));

private static object? Read(ThemeOptions theme, string propertyName) =>
typeof(ThemeOptions).GetProperty(propertyName)?.GetValue(theme);

private static string Camelize(string name) => char.ToLowerInvariant(name[0]) + name[1..];

private static string Pascalize(string name) => char.ToUpperInvariant(name[0]) + name[1..];
}
32 changes: 32 additions & 0 deletions SW.Bitween.Api/Resources/Subscriptions/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ join document in _dbContext.Set<Document>() on subscriber.DocumentId equals docu
var result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize,
searchyRequest.PageIndex).ToListAsync();
await AttachSchedules(result);
StripAdapterProperties(result);

return new SearchyResponse<SubscriptionSearch>
{
Expand All @@ -113,6 +114,36 @@ join document in _dbContext.Set<Document>() on subscriber.DocumentId equals docu
};
}

/// <summary>Empties the adapter property collections on every returned row.</summary>
/// <remarks>
/// Adapter properties hold credentials — a storage key, an OAuth secret, the password for a
/// partner system. <c>Subscriptions/Get</c> replaces those with a sentinel before answering;
/// this list endpoint returned them verbatim, which put every integration's secrets one
/// request away from anyone allowed to see the list at all.
/// <para>
/// Emptied here rather than left out of the projection because the projection needs them:
/// the <c>rawsubscriptionproperties</c> filter searches inside their values, in memory, over
/// the rows this query produced. So they are loaded, used, and then dropped before the
/// response is shaped. Nothing reads them off this endpoint — both UIs configure a
/// subscription from <c>Subscriptions/Get</c>, and the list has never shown them.
/// </para>
/// <para>
/// Emptied rather than masked because masking can only hide what an adapter declares
/// <c>[Secure]</c>, and the external adapters declare nothing — masking here would have
/// looked like a fix while leaving those integrations exactly as exposed.
/// </para>
/// </remarks>
private static void StripAdapterProperties(List<SubscriptionSearch> rows)
{
foreach (var row in rows)
{
row.HandlerProperties = [];
row.MapperProperties = [];
row.ReceiverProperties = [];
row.ValidatorProperties = [];
}
}

/// <summary>Fills in each returned row's schedules.</summary>
/// <remarks>
/// A second query rather than part of the projection above: <c>Schedule.On</c> is a
Expand Down Expand Up @@ -185,6 +216,7 @@ private async Task<SearchyResponse<SubscriptionSearch>> SearchWithEdgeCases(
var page = data.Skip(searchyRequest.PageSize * searchyRequest.PageIndex)
.Take(searchyRequest.PageSize).ToList();
await AttachSchedules(page);
StripAdapterProperties(page);

return new SearchyResponse<SubscriptionSearch>
{
Expand Down
Loading
Loading