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
46 changes: 34 additions & 12 deletions docs/oidc-resource-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,30 @@ customer in this order.
1. No `sub` claim: 401.
2. A stored link for `sub` (provider `ExternalAuth.OpenIdConnect`, exact, case-sensitive match) selects the customer.
The customer must be active, not deleted, registered, and not a system account; otherwise 403. An inactive customer
is never reactivated.
is never reactivated. A link made by e-mail (point 4) is checked again on every use: once the account has
back-office access or GrandNode two-factor authentication, the link no longer serves it (403) until the account is
connected with its password.
3. Without a link the e-mail claim is needed (missing: 401). With `RequireEmailVerified = true` it must be verified
(otherwise 401).
4. A customer with the same e-mail address is linked on first use, and the link is stored.
* With `RequireEmailVerified = true` this **includes back-office accounts** (administrators, vendors, store staff):
the identity provider is trusted for verified e-mail addresses. Secure the provider accordingly (verify e-mail
addresses, restrict self-registration and e-mail changes).
* With `RequireEmailVerified = false` back-office accounts are **never** linked by e-mail.
4. A customer with the same e-mail address is linked on first use, and the link is stored, marked as made by e-mail.
* **Back-office accounts** (admin panel access, vendors, store staff) and accounts that use **GrandNode two-factor
authentication** (enabled for the customer and in the store) are **never** linked by e-mail, verified or not
(403 without a link). A provider where someone can mark an address as verified without owning it (an
administrator of another organization or realm creating a user) must not be enough to take over such an account.
They are connected once with the account's password: through the browser sign-in plugin (section 11), which asks
for the store password and two-factor code; their tokens then use that link.
* Ordinary customers are linked by a verified e-mail. With `RequireEmailVerified = false` they are linked by an
unverified one too: only for providers that never send `email_verified` and do not let users choose an address.
The browser sign-in does not take over such a link as it is: it asks for the password whenever the provider
does not report the e-mail as verified.
5. System accounts and guests are never mapped.
6. One identity per customer: when the customer is already linked to another `sub`, the new subject is refused (403).
One identity per customer is enforced by the link's identifier, which is derived from the customer, so two
concurrent requests with different subjects cannot both link the same customer.
Links do not record the provider instance, so after switching `Authority` to another provider the customers
linked by the previous one are refused until their link is removed.
Links do not record the provider instance, and a `sub` is unique only within one provider. Before switching
`Authority` to another provider (or realm), remove the `ExternalAuth.OpenIdConnect` links: a subject of the new
provider equal to a stored one would select the old customer, and customers linked by the previous provider would be
refused as linked to another identity.
7. With `RegisterCustomersPerStore` enabled an account of another store is refused (403) and lookups are store-scoped.

**Per-store identity.** With `RegisterCustomersPerStore` enabled the same person can have a separate customer in each
Expand Down Expand Up @@ -169,6 +179,16 @@ A token alone grants no rights. To let a user call the API:
2. in GrandNode add the (auto-registered or existing) customer to a **customer group** whose permissions include
*Access admin panel* and the permissions the calls need (or use a group created for API access).

The customer must be **linked** to the provider identity, and a link made by e-mail does not serve an account with
admin panel access, a vendor or a staff store (section 3.1). Either:

* connect the account once with the browser sign-in plugin (section 11), which asks for its password; or
* without that plugin, let the token register the account (`AutoRegister = true`, section 3.2) and grant the access
to that account: a link made by the registration is not a link made by e-mail.

Granting back-office access to a customer linked by e-mail stops its tokens (403) until the account is connected with
its password.

### 3.4 Resource conditions

After the mapping the resource's conditions apply; a refusal is 403 without `WWW-Authenticate`:
Expand Down Expand Up @@ -528,7 +548,8 @@ After the provider signed the user in, the result depends on the GrandNode side:

| Situation | Result |
|---|---|
| A link for the provider's `sub` exists | The linked customer is signed in. Staff included; the admin panel access follows their existing permissions. The customer must be active, not deleted and registered. GrandNode two-factor authentication is not asked for; the provider's multi-factor policy applies. |
| A link for the provider's `sub` made by e-mail (by an access token, or by this sign-in without a password), and the account is now **staff** or uses **GrandNode two-factor authentication**, or the provider did **not** verify the e-mail | "Connect your account" (as below); the password sign-in confirms the existing link. |
| A link for the provider's `sub` exists (other cases) | The linked customer is signed in. Staff included; the admin panel access follows their existing permissions. The customer must be active, not deleted and registered. GrandNode two-factor authentication is not asked for; the provider's multi-factor policy applies. |
| No link; an account with the same e-mail exists, the provider **verified** the e-mail, the account is **not staff** and does not use GrandNode two-factor authentication | The account is linked and signed in at once. |
| No link; the account is **staff** (admin panel access, vendor, store staff), the account uses **GrandNode two-factor authentication** (enabled for the customer and in the store), or the provider did **not** verify the e-mail | "Connect your account": the page ends any current store session, and the visitor signs in **once** with the store password (and the two-factor code, when enabled). The connection is then completed (`/oidc/link/complete`); the signed-in account must have the same e-mail address. |
| No account with that e-mail | The regular registration form opens with the e-mail taken from the provider (read-only). When the provider **verified** the e-mail, the form has **no password fields** and the new account is connected to the provider identity. When it did **not**, the customer chooses a password as in any registration and the account is **not** connected; the customer connects it later with "Connect your account" (row above). Store registration type "Standard" becomes e-mail validation when the provider did not verify the e-mail; "E-mail validation" becomes standard when it did; "Admin approval" stays admin approval either way. New accounts get the **Registered** group only. |
Expand Down Expand Up @@ -645,9 +666,10 @@ Use the project of section 7. Create a **separate** application for the browser
staff account always needs the store password (and its 2FA).
* The button never replaces another signed-in account: a visitor signed in to the store as someone else gets the
failure page and stays signed in.
* A staff account is never linked by e-mail alone, but the one who completes the password sign-in and holds the
provider identity owns the link; keep the provider's e-mail verification and e-mail change policy strict (same advice
as section 3.1).
* A staff account is never linked by e-mail alone, neither here nor by an access token (section 3.1), and a link
made by e-mail before the account became staff asks for the password again; but the one who
completes the password sign-in and holds the provider identity owns the link; keep the provider's e-mail
verification and e-mail change policy strict.
* **Logout is local.** Signing out of GrandNode does not end the provider session; the next click on the button signs
in again without a prompt while the provider session lasts.
* Provider tokens are not stored. The only data kept from the provider is the `sub`/e-mail link, plus the pending
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ namespace Grand.Business.Authentication.Services;

/// <summary>
/// Maps a validated access token or a browser sign-in to a customer: stored subject link; customer with the verified e-mail (link
/// stored); registration with the Registered group only. Only active, registered, non-system accounts are used,
/// stored and marked as made by e-mail; never a back-office account or an account with two-factor authentication, which connect
/// only with their password); registration with the Registered group only. A link made by e-mail is re-checked on every use
/// against the rules of a first link. Only active, registered, non-system accounts are used,
/// and one customer holds at most one identity (enforced by the link's identifier, see <see cref="OidcLinkId" />).
/// With per-store identity a subject has one link per store (plus the link of a customer of no store, valid in
/// every store). A unique index on (provider, identifier, store) makes the first concurrent requests of one user
Expand All @@ -32,6 +34,9 @@ public class OidcCustomerResolver : IOidcCustomerResolver
{
public const string ProviderSystemName = "ExternalAuth.OpenIdConnect";

public const string AccountConnectionRequiredDescription =
"The account must be connected to the identity with its password first";

private readonly IRepository<ExternalAuthentication> _externalAuthenticationRepository;
private readonly ICustomerService _customerService;
private readonly ICustomerManagerService _customerManagerService;
Expand Down Expand Up @@ -77,7 +82,15 @@ public virtual async Task<OidcCustomerResult> Resolve(ClaimsPrincipal principal,

var link = await GetLink(sub, storeId);
if (link != null)
return await Usable(await _customerService.GetCustomerById(link.CustomerId), sub, storeId);
{
var linkedCustomer = await _customerService.GetCustomerById(link.CustomerId);
var linkedResult = await Usable(linkedCustomer, sub, storeId);
//a link made by e-mail proves no more than the e-mail: it stops serving an account that has since
//become one that connects only with its password
if (linkedResult.Succeeded && link.LinkedByEmail && await RequiresPassword(linkedCustomer, storeId))
return AccountConnectionRequired(linkedCustomer, sub);
return linkedResult;
}

var email = principal.FindFirst(_config.Claims.Email)?.Value;
if (string.IsNullOrWhiteSpace(email))
Expand All @@ -98,20 +111,17 @@ public virtual async Task<OidcCustomerResult> Resolve(ClaimsPrincipal principal,
if (!result.Succeeded)
return result;

//an e-mail nobody verified could take over a back-office account, so those are never linked by it
if (!_config.RequireEmailVerified && await IsBackOfficeAccount(customer))
{
_logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is a back-office account and the e-mail is not verified",
sub, customer.Id);
return OidcCustomerResult.Forbidden("Back-office accounts can only be linked with a verified e-mail");
}

//one identity per customer - a second subject with the same e-mail must not take over the account; the
//link's identifier enforces it against concurrent requests, this check gives the answer in the normal case
if (await HasOtherLink(customer, sub))
return LinkedToOtherIdentity(customer, sub);

var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId);
//a back-office account and an account protected by two-factor authentication are never linked by e-mail,
//verified or not; they connect with the account's password (browser sign-in), and tokens then use that link
if (await RequiresPassword(customer, storeId))
return AccountConnectionRequired(customer, sub);

var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId, true);
if (other != null)
return LinkedToOtherIdentity(customer, sub);
if (linked?.Id != customer.Id)
Expand All @@ -132,7 +142,14 @@ public virtual async Task<OidcLoginResult> ResolveForLogin(string sub, string em
{
var linked = await _customerService.GetCustomerById(link.CustomerId);
var reason = await Check(linked, sub, storeId);
return reason == Unusable.None ? OidcLoginResult.SignIn(linked) : OidcLoginResult.Refused(ToRefusal(reason));
if (reason != Unusable.None)
return OidcLoginResult.Refused(ToRefusal(reason));

//a link made by e-mail (by an access token or an earlier sign-in) is held to the rules of a first link
if (link.LinkedByEmail && (!emailVerified || await RequiresPassword(linked, storeId)))
return OidcLoginResult.LinkRequired(linked);

return OidcLoginResult.SignIn(linked);
}

if (string.IsNullOrWhiteSpace(email))
Expand Down Expand Up @@ -164,29 +181,40 @@ public virtual async Task<OidcLoginResult> ResolveForLogin(string sub, string em

//the first link of a back-office account, of an account protected by two-factor authentication, and of any account
//whose e-mail nobody verified, needs the account's password (and its second factor)
if (!emailVerified || await IsBackOfficeAccount(customer) || await HasTwoFactor(customer, storeId))
if (!emailVerified || await RequiresPassword(customer, storeId))
return OidcLoginResult.LinkRequired(customer);

return await LinkIdentity(customer, sub, email, storeId) switch {
return await Link(customer, sub, email, storeId, true) switch {
OidcLinkResult.Linked => OidcLoginResult.SignIn(customer),
_ => OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity)
};
}

public virtual async Task<OidcLinkResult> LinkIdentity(Customer customer, string sub, string email, string storeId)
public virtual Task<OidcLinkResult> LinkIdentity(Customer customer, string sub, string email, string storeId) =>
Link(customer, sub, email, storeId, false);

private async Task<OidcLinkResult> Link(Customer customer, string sub, string email, string storeId, bool byEmail)
{
ArgumentNullException.ThrowIfNull(customer);
ArgumentException.ThrowIfNullOrEmpty(sub);

//the link this store would use - with per-store identity a link of another store does not block this one
var existing = await GetLink(sub, storeId);
if (existing != null)
return existing.CustomerId == customer.Id ? OidcLinkResult.Linked : OidcLinkResult.SubjectLinkedToOtherCustomer;
{
if (existing.CustomerId != customer.Id)
return OidcLinkResult.SubjectLinkedToOtherCustomer;

//the account's password now stands behind a link made by e-mail
if (!byEmail && existing.LinkedByEmail)
await _externalAuthenticationRepository.UpdateField(existing.Id, x => x.LinkedByEmail, false);
return OidcLinkResult.Linked;
}

if (await HasOtherLink(customer, sub))
return OidcLinkResult.CustomerLinkedToOtherSubject;

var (winner, other) = await LinkOrGetWinner(customer, sub, email ?? customer.Email ?? "", storeId);
var (winner, other) = await LinkOrGetWinner(customer, sub, email ?? customer.Email ?? "", storeId, byEmail);
if (other != null)
return OidcLinkResult.CustomerLinkedToOtherSubject;
if (winner?.Id != customer.Id)
Expand Down Expand Up @@ -237,7 +265,7 @@ private async Task<OidcCustomerResult> Register(string sub, string email, string
await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, normalizedEmail,
normalizedEmail, CommonHelper.GenerateRandomDigitCode(20), PasswordFormat.Hashed, storeId));

var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId);
var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId, false);
if (other != null || linked?.Id != customer.Id)
{
//lost the race: drop the account just created and use the one the winner linked
Expand Down Expand Up @@ -300,13 +328,20 @@ private OidcCustomerResult LinkedToOtherIdentity(Customer customer, string sub)
return OidcCustomerResult.Forbidden("The customer account is linked to another identity");
}

private OidcCustomerResult AccountConnectionRequired(Customer customer, string sub)
{
_logger.LogWarning("Access token subject {Subject}: customer {CustomerId} must be connected with its password",
sub, customer.Id);
return OidcCustomerResult.Forbidden(AccountConnectionRequiredDescription);
}

/// <summary>
/// Stores the link under the customer's <see cref="OidcLinkId" />. When a concurrent request stored a link
/// first: the link of another subject now holding this customer (OtherLink), or the customer the subject's
/// winning link points to (Customer). Rethrows when the insert failed for another reason.
/// </summary>
private async Task<(Customer Customer, ExternalAuthentication OtherLink)> LinkOrGetWinner(Customer customer,
string sub, string email, string storeId)
string sub, string email, string storeId, bool byEmail)
{
var linkId = OidcLinkId(customer.Id);
try
Expand All @@ -318,7 +353,8 @@ await _externalAuthenticationRepository.InsertAsync(new ExternalAuthentication {
ExternalIdentifier = sub,
ExternalDisplayIdentifier = email,
ProviderSystemName = ProviderSystemName,
StoreId = LinkStoreId(customer)
StoreId = LinkStoreId(customer),
LinkedByEmail = byEmail
});
return (customer, null);
}
Expand Down Expand Up @@ -411,6 +447,10 @@ private async Task<bool> HasTwoFactor(Customer customer, string storeId) =>
customer.GetUserFieldFromEntity<bool>(SystemCustomerFieldNames.TwoFactorEnabled) &&
(await _settingService.LoadSetting<CustomerSettings>(storeId)).TwoFactorAuthenticationEnabled;

//accounts that are never linked by e-mail alone, and whose links made by e-mail stop serving them
private async Task<bool> RequiresPassword(Customer customer, string storeId) =>
await IsBackOfficeAccount(customer) || await HasTwoFactor(customer, storeId);

private async Task<bool> IsBackOfficeAccount(Customer customer) =>
!string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) ||
await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer);
Expand Down
Loading
Loading