diff --git a/docs/oidc-resource-server.md b/docs/oidc-resource-server.md new file mode 100644 index 0000000000..4739cc727d --- /dev/null +++ b/docs/oidc-resource-server.md @@ -0,0 +1,502 @@ +# OpenID Connect resource server + +How to let APIs, integrations, applications and MCP-class clients call GrandNode with access tokens issued by an +external OpenID Connect provider. Guides for Keycloak 26.8 and Zitadel (v4.19) and a Docker Compose test environment +are included. + +## 1. What it does + +GrandNode acts only as an OAuth 2.1 **resource server**. It validates access tokens (JWT) issued by one external +identity provider and never issues tokens, registers clients or shows a consent screen. + +* A token identifies a **customer**; the customer's **groups and permissions in GrandNode** decide what the call may do. +* Protected resources are configured in `appsettings.json` (the Web API, `/api`) or registered by plugins (their own + endpoints, for example a server for MCP-class clients). +* GrandNode publishes RFC 9728 protected resource metadata, so clients discover which provider to use. A 401 carries + a `WWW-Authenticate` header with `resource_metadata`. +* Off by default (`OidcResourceServer:Enabled = false`): no scheme, no metadata endpoints, no behavior change. +* One identity provider at a time. Not covered: browser login of storefront or admin panel users, opaque tokens + (see [Introspection](#8-introspection)). + +Accepted signing algorithms: RS256/384/512, PS256/384/512, ES256/384/512. Tokens signed with HS\*, or with `alg: none`, +are refused by the OpenID Connect scheme. + +## 2. Configuration reference + +Section `OidcResourceServer` of `appsettings.json` (environment variables use `__`, for example +`OidcResourceServer__Authority`). + +| Key | Default | Meaning | +|---|---|---| +| `Enabled` | `false` | Turns the feature on. | +| `Authority` | empty | The provider's **issuer**. Discovery document: `{Authority}/.well-known/openid-configuration`. Must equal the `issuer` value of that document and the `iss` of the tokens, character for character (see [Routing](#46-routing-of-tokens)). Absolute URL, `https` outside Development. | +| `RequireHttpsMetadata` | `true` | Require https for the discovery document. Set `false` only for an `http` Authority in Development. | +| `ClockSkewSeconds` | `60` | Tolerated clock difference when checking `exp`/`nbf`. 0-300. | +| `Claims:Subject` | `sub` | Claim holding the stable user id. | +| `Claims:Email` | `email` | Claim holding the e-mail address. | +| `Claims:EmailVerified` | `email_verified` | Claim holding the verification flag (JSON boolean or the string `true`, case-insensitive). | +| `RequireEmailVerified` | `true` | An unverified e-mail never links to an account and never registers one. See [linking rules](#31-linking-rules). | +| `AutoRegister` | `false` | Register an unknown user on first use (customer group **Registered** only). | +| `Resources` | one entry for `/api` | Protected resources, below. | + +Each entry of `Resources` (plugins register the same object, section 5): + +| Key | Default | Meaning | +|---|---|---| +| `Name` | required | Unique name, for example `BackendApi`. | +| `PathPrefix` | required | Starts with `/`, not `/` alone. Matched case-insensitively on a segment boundary: `/api` covers `/api` and `/api/x`, not `/apix`. Prefixes must not overlap. | +| `Resource` | empty | Canonical resource URI (RFC 8707), absolute, no fragment. Empty: the audience is the **store URL + `PathPrefix`**, see below. | +| `Audiences` | `[]` | Extra accepted `aud` values, for example a Zitadel project id. | +| `AuthorizedParties` | `[]` | If not empty, the token's `azp` (fallback `client_id`) must be listed. | +| `ScopesSupported` | `[]` | Advertised in the metadata document. | +| `RequiredScopes` | `[]` | Every listed scope must be in the token's `scope` (space separated) or `scp` (string or repeated claim). Compared exactly. Missing: 403 `insufficient_scope`. | +| `RequireAdminPanelAccess` | `false` | The customer needs the `ManageAccessAdminPanel` permission. | +| `DenyVendors` | `false` | Refuse customers that belong to a vendor. The shipped `BackendApi` entry sets `true`. | +| `DenyStoreManagers` | `false` | Refuse store manager accounts. The shipped `BackendApi` entry sets `true`. | + +`offline_access` is never advertised in `scopes_supported` or in a challenge `scope`. + +Startup validation (outside Development the application refuses to start, in Development it warns): `Authority` +absolute, https (an `http` Authority is accepted in Development only, with `RequireHttpsMetadata = false`); at least one +resource (configuration or plugins); each resource has a name and a valid prefix; names and prefixes unique and not +nested; `Resource` absolute without fragment; `ClockSkewSeconds` 0-300; claim names not empty. + +### Audience + +A token is accepted when its `aud` contains the resource URI or one of `Audiences`. + +* With an empty `Resource` the resource URI is the **URL of the GrandNode store serving the request host** (field *Store + URL* in the admin panel) plus `PathPrefix`, for example `https://shop.example.com/api`. The store is the one whose + *Hosts* list the request host, compared with its port first (`localhost:44350`, as the installer records the host of + the install request) and then without it (`shop.example.com`); the store cookie is never used. When no store lists the + host, a single-store installation uses its only store; with several stores the request has no audience and every token + is rejected (and the metadata endpoints answer 404). The URI itself always comes from the *Store URL*, never from the + `Host` header. A request whose token is accepted runs in that same store (prices, settings, per-store accounts), even + when it also carries another store's cookie; without an accepted token the store cookie keeps its usual priority. +* Behind a TLS-terminating proxy the *Store URL* must be the public `https://` URL, or the audience will not match. + Also set `Security:UseForwardedHeaders = true` so that `X-Forwarded-Proto` makes the request scheme `https` + (ASP.NET Core applies it only from a trusted proxy, loopback by default): the metadata endpoint picks between + resources sharing a path by the scheme and host the request reached, and an `http` scheme there does not match an + `https` resource URI. `X-Forwarded-Host` is not applied, so the proxy has to pass the original `Host` header, the + one listed in the store's *Hosts*. +* Multi-store: every store has its own audience (`https://shop-a.example.com/api`, `https://shop-b.example.com/api`), + chosen by the host the request reached, so a token issued for store B is not accepted on store A's host. To accept + one audience only, set `Resource` explicitly. +* A store without a *Store URL* and a resource without `Resource` have no resource URI: tokens are rejected and the + metadata endpoint answers 404, with a warning in the log naming the resource. A request for a host no store serves + also gets 404, logged at Debug level only, since any anonymous request can name such a host. +* Comparison is canonical: scheme and host lower-case, default port dropped, no trailing slash, path exact. + +### Example for the Web API + +```json +"OidcResourceServer": { + "Enabled": true, + "Authority": "https://idp.example.com/realms/shop", + "RequireEmailVerified": true, + "AutoRegister": false, + "Resources": [ + { + "Name": "BackendApi", + "PathPrefix": "/api", + "Resource": "https://shop.example.com/api", + "ScopesSupported": [ "grandnode.api" ], + "RequiredScopes": [ "grandnode.api" ], + "RequireAdminPanelAccess": true, + "DenyVendors": true, + "DenyStoreManagers": true + } + ] +} +``` + +**Always set `RequiredScopes`** (and, with client-id style audiences such as Zitadel's, `AuthorizedParties`). With +client-id style audiences an ID token of the same client could otherwise pass the audience check. `RequiredScopes` +is only as strong as the provider's rule for issuing the scope: a provider that puts the scope into every access token +of its instance turns it into a formality (see Zitadel, section 7, for what gates access there). + +## 3. From token to customer + +After the signature, issuer, lifetime, audience, authorized party and scopes are valid, the token is mapped to a +customer in this order. + +### 3.1 Linking rules + +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. +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. +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. +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 +store, so one `sub` gets one link per store: the link records the store of the linked customer, and a request uses the +link of its own store. In a store where the subject has no link yet, the rules above link (or register) that store's +customer. A customer of no store, such as a back-office account, has one link without a store, and it works in every +store. A link without a store to a customer of another store (stored while per-store identity was off) is ignored, so +the store's own customer is linked next to it. Without per-store identity a link has no store and is used in every +store. + +### 3.2 Automatic registration + +`AutoRegister` is `false` by default. When no customer matches, the answer is 403 "No customer account matches the +token"; the customer has to exist already (it is then linked by the verified e-mail, section 3.1). Set `AutoRegister = +true` to opt in to registering an unknown user on first use; then: + +* the new customer gets the **Registered** group only, never an admin or vendor group; +* store registration set to *Disabled* gives 403; +* the e-mail address becomes the user name; when that user name is already taken, 403; +* the account is created with a random password; the user does not sign in with it. + +### 3.3 Granting access + +A token alone grants no rights. To let a user call the API: + +1. let the provider issue the user a token with the `grandnode.api` scope (provider side, below); +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). + +### 3.4 Resource conditions + +After the mapping the resource's conditions apply; a refusal is 403 without `WWW-Authenticate`: +`DenyVendors`, `DenyStoreManagers`, `RequireAdminPanelAccess`. + +The Web API's controllers apply no vendor or store scoping, so the shipped `BackendApi` resource sets `DenyVendors` and +`DenyStoreManagers` to `true`: vendor accounts and store managers are refused even with admin panel access. Keep both +on unless every endpoint the resource covers scopes its data. + +### 3.5 Web API (`/api`) + +* OIDC tokens are accepted on `/api` **next to** the existing `SecretKey` tokens, which keep working. +* Access still requires the `ManageAccessAdminPanel` permission and passes the admin IP allow-list. +* The per-user *API users* opt-in does **not** apply to OIDC tokens. Who may call the API is decided by which users + the provider grants the `grandnode.api` scope, plus their GrandNode groups. +* `BackendAPI:Enabled = false` switches the Web API off for both token kinds. For OIDC tokens this happens before the + token is looked at: the Web API module withdraws its paths (`IOidcDisabledPathProvider`), so every resource on or + under `/api` is left out of the registry (logged at startup), no metadata is published for it, and a provider token + there goes to the `SecretKey` scheme, which refuses it (401). No customer is registered, linked or looked up and the + store is not pinned. The setting is read at startup; changing it needs a restart. An OIDC-only setup still needs + `BackendAPI:Enabled = true` and a strong `BackendAPI:SecretKey` (at least 32 characters; startup check outside + Development), although no SecretKey token has to be issued. +* The Web API module itself is off by default: set `FeatureManagement:Grand.Module.Api = true` (environment variable + `FeatureManagement__Grand.Module.Api`). Without it `/api/...` answers with the storefront's 404 page. + +## 4. Errors, metadata, routing + +### 4.1 Errors + +| Situation | Status | `WWW-Authenticate` | +|---|---|---| +| No token on a resource | 401 | `Bearer resource_metadata="...", scope=""` | +| Bad signature, issuer, lifetime or algorithm; wrong audience or authorized party; missing `sub` or e-mail; unverified e-mail | 401 | `Bearer error="invalid_token", error_description="...", resource_metadata="..."` | +| Required scope missing | 403 | `Bearer error="insufficient_scope", error_description="...", scope="", resource_metadata="..."` | +| Customer inactive or deleted, registration off, link conflict, resource condition refused | 403 | none | +| GrandNode permission check inside the endpoint | 403 | unchanged | +| The provider's discovery document or signing keys cannot be obtained (provider down, wrong `Authority`) | 503 | none | + +The body is `application/problem+json` (ProblemDetails). `error_description` is a fixed text; a token is never logged +or echoed. The 503 carries `Retry-After: 30` and is logged at Error level with the metadata address; clients keep +their token and retry, instead of discarding a valid token as `invalid_token`. The provider is tried again on the next +request, so tokens are judged normally as soon as it answers. + +### 4.2 Metadata URLs + +| URL | Document | +|---|---| +| `/.well-known/oauth-protected-resource{path of the resource URI}` | Metadata of one resource, for example `/.well-known/oauth-protected-resource/api` for `https://shop.example.com/api` | +| `/.well-known/oauth-protected-resource` | Store root: `resource` is the store URL, scopes are the union of all `ScopesSupported`. 404 when the store URL has a path (`https://example.com/shop`): the root URL describes the bare host (RFC 9728 section 3.3), and that store is described at `/.well-known/oauth-protected-resource/shop` | + +As RFC 9728 section 3.1 requires, the well-known segment goes between the host and the path of the resource URI, at +the host of that URI: + +| Resource URI | Metadata URL | +|---|---| +| `https://shop.example.com/api` (store URL + `PathPrefix`) | `https://shop.example.com/.well-known/oauth-protected-resource/api` | +| `https://example.com/shop/api` (store under `/shop`) | `https://example.com/.well-known/oauth-protected-resource/shop/api` | +| `https://api.example.com/v1` (explicit `Resource`) | `https://api.example.com/.well-known/oauth-protected-resource/v1` | + +The `resource_metadata` value of the challenge is this URL. A request is matched on the path of each resource URI; +when two resources share a path, the one on the host the request reached wins. With an explicit `Resource` on another +host, that host has to reach GrandNode for the document to be served. + +Anonymous, `application/json`: + +```json +{ + "resource": "https://shop.example.com/api", + "authorization_servers": [ "https://idp.example.com/realms/shop" ], + "scopes_supported": [ "grandnode.api" ], + "bearer_methods_supported": [ "header" ], + "resource_name": "BackendApi" +} +``` + +Unknown paths and a disabled feature give 404. + +### 4.3 PathBase + +When GrandNode is published under a path base (for example `https://example.com/shop`), the metadata URL is at the +host root: `https://example.com/.well-known/oauth-protected-resource/shop/api`, both in the challenge and for clients +that build it themselves. The application cannot answer outside its path base, so the reverse proxy has to route +`/.well-known/oauth-protected-resource/...` at the host root to the application (keeping the full path, here +`/.well-known/oauth-protected-resource/shop/api`). The same holds for a store whose *Store URL* has a path +(`https://example.com/shop`): its documents are at `/.well-known/oauth-protected-resource/shop/...` on the host root, +and the root document `/.well-known/oauth-protected-resource` answers 404 for it. + +### 4.4 Endpoints without `[Authorize]` + +Under a resource prefix an endpoint that has no `[Authorize]` attribute does **not** answer 401 for a refused token: +the request continues as cookie or guest customer. Protect plugin endpoints with +`[Authorize(AuthenticationSchemes = "Oidc")]` (section 5). + +### 4.5 Impersonation + +Impersonation is not applied to token-authenticated customers. The OIDC request customer resolver runs before the +built-in cookie step in `WorkContextSetter.CurrentCustomer`, and impersonation happens only for the cookie customer +(pinned by `OidcWorkContextTests.ValidToken_TokenCustomerNeverImpersonated`). + +### 4.6 Routing of tokens + +On a resource path the Web API's `Bearer` scheme hands a request to the `Oidc` scheme when it carries no readable +bearer JWT (so the 401 is the OIDC challenge) or when the token's `alg` is one the `Oidc` scheme accepts (RS256/384/512, +PS256/384/512, ES256/384/512). Every other token, HS\* in particular (GrandNode's own `SecretKey` tokens), stays with +the `Bearer` scheme whatever `iss` it claims; the `Oidc` scheme would refuse an HMAC token anyway. The algorithm is read +from the unvalidated header for routing only, once per request; the scheme the request lands on validates the token. +A bearer value that does not parse as a JWT (for example three arbitrary dot-separated segments) counts as no +token and gets the OIDC challenge. Only the `Bearer` scheme forwards: other JwtBearer schemes (the frontend API, +plugins) keep their own validation and challenge, also on a resource path. + +`Authority` must equal the `issuer` of the provider's discovery document. In container setups the application's +`Authority` URL and the token `iss` must be the same string: use the public host name for both (for example +`http://localhost:8080/realms/grandnode` for Keycloak started by `docs/oidc/docker-compose.yml`). + +## 5. Protecting a plugin's endpoints + +A plugin registers a provider with its resources and protects its controllers with the `Oidc` scheme. + +```csharp +public class MyResources : IOidcProtectedResourceProvider +{ + public IEnumerable GetResources() => [ + new OidcProtectedResource { + Name = "MyPlugin", PathPrefix = "/my-plugin", + ScopesSupported = ["myplugin.use"], RequiredScopes = ["myplugin.use"], + RequireAdminPanelAccess = true, DenyVendors = true, DenyStoreManagers = true + } + ]; +} + +// in the plugin's IStartupApplication.ConfigureServices +services.AddSingleton(); + +// on the plugin's controllers +[Authorize(AuthenticationSchemes = "Oidc")] +``` + +The plugin's resource appears in the metadata documents and challenges like a configured one. A plugin that defines +scope hierarchies checks them itself: scope comparison in the resource server is exact. + +## 6. Keycloak 26.8 + +Tested version: Keycloak 26.8.0 (image `quay.io/keycloak/keycloak:26.8.0`). Documentation: +. + +1. **Realm.** Create a realm, for example `grandnode`. Issuer: `https://idp.example.com/realms/grandnode`; this is + `OidcResourceServer:Authority`. +2. **Client scope** *Client scopes > Create client scope*: name `grandnode.api`, type **Optional**, protocol + OpenID Connect, *Include in token scope* on. +3. **Audience mapper** in that scope (*Mappers > Configure a new mapper > Audience*): *Included Custom Audience* = + the resource URI, for example `https://shop.example.com/api` (store URL + prefix, or `Resource`); *Add to access + token* on, *Add to ID token* off. Add one mapper per resource. +4. **Interactive applications** (public client): *Clients > Create client*, *Client authentication* off, *Standard flow* + on, valid redirect URIs of the application; under *Advanced > Advanced settings* set *Proof Key for Code Exchange + Code Challenge Method* to `S256`. Add `grandnode.api` to the client's optional scopes. The application requests + `scope=openid grandnode.api`. +5. **E-mail in the access token.** The default `email` client scope adds `email` and `email_verified` to access + tokens; keep it assigned to the client. Users must have a verified e-mail address (*Users > Email verified*, or + enable *Verify email* in the realm login settings). +6. **GrandNode.** `Authority` as in step 1, `RequiredScopes: ["grandnode.api"]`, `Resource` as in step 3 (or empty + when the audience is the store URL + prefix). + +Experimental features (Keycloak marks both as experimental; enable with `--features=resource-indicators,cimd`): + +* **Resource indicators (RFC 8707)**: Keycloak then processes the `resource` parameter and rejects unknown values with + `invalid_target` (at the token endpoint). A `resource` equal to the *Included Custom Audience* of the scope's Audience + mapper (steps 2-3) is accepted, so that setup works with the feature on or off. Alternatively create a client that + represents the resource and set its client attribute `resource_url` to the resource URI (Admin REST API or realm + import); the application's client gets an Audience mapper with *Included Client Audience* pointing at that client. + Without the feature Keycloak ignores `resource` and the audience comes from the scope's Audience mapper (steps 2-3), + which is the setup used for GrandNode by default. +* **Dynamic client registration (RFC 7591)**, for clients that register themselves: *Clients > Client registration > + Anonymous access policies*: *Allowed Client Scopes* must include `grandnode.api`; *Trusted Hosts* the host names or + addresses the registration requests come from; *Allowed Registration Web Origins* the web origin of browser based + clients. DCR is deprecated in MCP 2026-07-28 but still allowed. +* **Client ID Metadata Documents (CIMD)**: *Realm settings > Client policies > Profiles*: create a profile with the + executor `client-id-metadata-document` (*Trusted domains*, *Restrict same domain*, *Allow http scheme* off in + production; *Only Allow Confidential Client* off for public clients using PKCE; *Resource indicator allow list* + needs `resource-indicators`). Then *Policies*: create a policy with the condition `client-id-uri` (*URI scheme* + `https`, *Trusted domains*; an empty list never matches) and the profile. + +## 7. Zitadel (v4.19) + +Tested version: Zitadel v4.19.4 (image `ghcr.io/zitadel/zitadel:v4.19.4`). Documentation: +, , +. + +Zitadel differs from Keycloak in four points that shape the configuration: + +* Access tokens are **opaque by default**; GrandNode needs JWT. +* The `resource` parameter is ignored; the audience is the project id (and the client ids). +* A JWT access token has **no `email`, `email_verified` and `scope` claims**. +* Custom scopes are not defined by the provider, so a `scope` claim can only come from an Action. + +Steps: + +1. **Project and application.** Create a project (note its id) and turn on **Assert Roles on Authentication** in its + settings (Zitadel sends the user's grants to the Actions target only then, or when the client requests + `urn:zitadel:iam:org:projects:roles`). Create an application of type *Web* or *User Agent* with + authentication method *PKCE* (public client, S256), the application's redirect URIs (`http` redirect URIs need + *Development Mode*) and **Auth Token Type = JWT** (*Token Settings*). Note the client id. +2. **Audience.** Zitadel puts the project id and the client ids of all applications of the project into `aud`. + Requesting the scope `urn:zitadel:iam:org:project:id:{projectId}:aud` makes the project id explicit; it is + harmless when it is already there. Zitadel sends the client in `client_id`, not `azp`. +3. **E-mail, entitlement and the Actions v2 target.** Zitadel Actions v1 (`api.v1.claims.setClaim`, complement token + flow) is deprecated and planned for removal in Zitadel v5. Use an Actions v2 target on the function + `preaccesstoken`. `docs/oidc/zitadel-email-target.js` is a small HTTP target that answers with `append_claims`: + `email` and `email_verified` (from the payload), and `scope = grandnode.api` **only when the user has the project + role `grandnode.api` in the GrandNode project** (`user_grants` of the payload; role key `GRANDNODE_ROLE`, project + `GRANDNODE_PROJECT_ID`, both environment variables of the target). Without the role, or without + `GRANDNODE_PROJECT_ID`, no scope is added and GrandNode answers 403 `insufficient_scope`. To use it: create the + role `grandnode.api` in the project (step 1), grant it to the users who may call the API, and set + `GRANDNODE_PROJECT_ID` in the `.env` file of the test environment. `preaccesstoken` runs for access tokens only, so + ID tokens never carry the scope. In the test environment the target runs as the service `zitadel-email-target`. + Create the target and the execution with the management API (token of an admin or service user with the right to + manage actions): + + ```bash + curl -X POST 'http://localhost:8081/v2/actions/targets' \ + -H 'Content-Type: application/json' -H 'Authorization: Bearer ' \ + -d '{ "name": "grandnode-claims", "restCall": { "interruptOnError": true }, + "endpoint": "http://zitadel-email-target:8090/", "timeout": "10s" }' + + curl -X PUT 'http://localhost:8081/v2/actions/executions' \ + -H 'Content-Type: application/json' -H 'Authorization: Bearer ' \ + -d '{ "condition": { "function": { "name": "preaccesstoken" } }, "targets": [ "" ] }' + ``` + + A "call" target uses the response; a webhook ignores it. Zitadel refuses targets on private addresses by default + (`HTTPClient.DenyList`); the test compose file overrides it (test only, it restores SSRF exposure: do not copy to + production). In production use an HTTPS endpoint and verify the + `ZITADEL-Signature` header (the sample does not). +4. **GrandNode.** + + ```json + "OidcResourceServer": { + "Enabled": true, + "Authority": "https://zitadel.example.com", + "Resources": [ + { + "Name": "BackendApi", + "PathPrefix": "/api", + "Audiences": [ "" ], + "AuthorizedParties": [ "" ], + "ScopesSupported": [ "grandnode.api" ], + "RequiredScopes": [ "grandnode.api" ], + "RequireAdminPanelAccess": true, + "DenyVendors": true, + "DenyStoreManagers": true + } + ] + } + ``` + + `AuthorizedParties` is **mandatory** for Zitadel: the audience is a project id shared by every application of the + project, so only the authorized party (`azp`, for Zitadel the `client_id` claim) binds the token to your client. With + `Audiences` set the store URL based audience is still accepted next to it. + + **What gates access with Zitadel**, from the outside in: (1) the token is a valid JWT of the instance's issuer; (2) + `aud` contains the project id (or the store resource URI); (3) `azp` is one of your clients; (4) the `scope` claim + contains `grandnode.api`, which the target adds only for users holding the project role; (5) the GrandNode customer + exists or is registered, is in a group with *Access admin panel* and the needed permissions. Step 4 is only as strong + as who may grant the project role or edit Zitadel Actions. If you do not run the target, leave `RequiredScopes` + **empty** for Zitadel (a required scope would reject every token) and rely on (1)-(3) and (5); then any user of the + instance who can log in to an allowed client reaches the GrandNode permission check, and a user that is + auto-registered holds the Registered group only, which has no admin panel access. `AutoRegister` is off by default; + turn it on only when every user of the provider may get a store account. +5. **Dynamic client registration** (RFC 7591) is **off by default** in Zitadel. Two modes: with a token of a user + holding the `project.app.register_dynamic` permission, or open (`dynamicClientRegistration.allowUnauthenticated = + true`), which MCP-class clients need. See . + Clients registered this way still need the audience scope of step 2. + +Fallback for a Zitadel version that still runs Actions v1 (property names from the v1 reference; the role check uses +`ctx.v1.user.grants`, replace the project id): trigger *Complement Token > Pre access token creation*: + +```js +function addClaims(ctx, api) { + if (!ctx.v1.user || !ctx.v1.user.human) return; + api.v1.claims.setClaim('email', ctx.v1.user.human.email); + api.v1.claims.setClaim('email_verified', ctx.v1.user.human.isEmailVerified); + var entitled = (ctx.v1.user.grants || []).some(function (g) { + return g.projectId === '' && g.roles && g.roles.indexOf('grandnode.api') >= 0; + }); + if (entitled) api.v1.claims.setClaim('scope', 'grandnode.api'); +} +``` + +## 8. Introspection + +Not supported. Keycloak and Zitadel (application set to JWT) issue JWT access tokens that are validated locally +against the provider's JWKS. Introspection would need GrandNode client credentials at the provider and a provider round +trip per request (or a cache with its own revocation window). A provider that can issue opaque tokens only is not +supported. + +## 9. Deprecated: API users + +API users (`SecretKey` tokens signed by GrandNode, *API users* in the admin panel, `BackendAPI` settings) are +**deprecated** in favor of the resource server. They keep working on the same endpoints until they are removed in a +future major version. To migrate: configure the resource server, let the integration obtain tokens from the provider +(client credentials or authorization code with PKCE), add the integration's customer to a group with the permissions +the calls need, then remove the API user. + +## 10. Test environment + +`docs/oidc/docker-compose.yml` starts Keycloak 26.8.0 (`http://localhost:8080`), Zitadel v4.19.4 +(`http://localhost:8081`) with PostgreSQL 17, and the Zitadel claim target. Placeholder credentials, http only, for +tests only: + +Create a `docs/oidc/.env` file with your own values (the compose file refuses to start while one is missing): + +```ini +KC_ADMIN_USER=admin +KC_ADMIN_PASSWORD= +ZITADEL_DB_PASSWORD= +ZITADEL_MASTERKEY= +ZITADEL_ADMIN_PASSWORD= +# after creating the GrandNode project in Zitadel, then `docker compose ... up -d zitadel-email-target` +GRANDNODE_PROJECT_ID= +``` + +```bash +docker compose -f docs/oidc/docker-compose.yml up -d +``` + +Ports are published on `127.0.0.1` only. + +Use the URLs above as `Authority` (`http://localhost:8080/realms/` for Keycloak, `http://localhost:8081` for +Zitadel) with `RequireHttpsMetadata: false` in Development. + +Sources used for provider specifics (checked 2026-10-08): Keycloak 26.8 MCP guide and release notes, Zitadel v4.19.4 +claims, scopes, Actions v2 and dynamic client registration documentation; MCP authorization 2026-07-28; RFC 9728, +RFC 8707, RFC 6750, RFC 7591. diff --git a/docs/oidc/.gitignore b/docs/oidc/.gitignore new file mode 100644 index 0000000000..4c49bd78f1 --- /dev/null +++ b/docs/oidc/.gitignore @@ -0,0 +1 @@ +.env diff --git a/docs/oidc/docker-compose.yml b/docs/oidc/docker-compose.yml new file mode 100644 index 0000000000..94d2b2eb3a --- /dev/null +++ b/docs/oidc/docker-compose.yml @@ -0,0 +1,92 @@ +# TEST ENVIRONMENT ONLY. Placeholder credentials, plain http, no persistence guarantees. +# Never use this file for a real installation. Guide: docs/oidc-resource-server.md +# +# docker compose -f docs/oidc/docker-compose.yml up -d +# +# Required variables (a .env file next to this file, example in docs/oidc-resource-server.md section 10): +# KC_ADMIN_USER, KC_ADMIN_PASSWORD, ZITADEL_DB_PASSWORD, ZITADEL_MASTERKEY (32 chars), ZITADEL_ADMIN_PASSWORD +# Optional: GRANDNODE_PROJECT_ID (Zitadel project id, for the claim target) +# Ports are bound to 127.0.0.1 only. +# +# Keycloak 26.8.0 http://localhost:8080 admin user: KC_ADMIN_USER / KC_ADMIN_PASSWORD +# Zitadel v4.19.4 http://localhost:8081 admin: zitadel-admin@zitadel.localhost / ZITADEL_ADMIN_PASSWORD +# +# Use exactly these public URLs as OidcResourceServer:Authority and in the browser - the token issuer is built from them +# (Keycloak: http://localhost:8080/realms/, Zitadel: http://localhost:8081). + +services: + keycloak: + image: quay.io/keycloak/keycloak:26.8.0 + # resource-indicators and cimd are experimental features in Keycloak 26.8 + command: start-dev --features=resource-indicators,cimd + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER:?set KC_ADMIN_USER} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?set KC_ADMIN_PASSWORD} + ports: + - "127.0.0.1:8080:8080" + + zitadel-db: + image: postgres:17 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${ZITADEL_DB_PASSWORD:?set ZITADEL_DB_PASSWORD} + POSTGRES_DB: zitadel + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - zitadel-db-data:/var/lib/postgresql/data + + zitadel: + # latest stable release at the time of writing (2026-10-08) + image: ghcr.io/zitadel/zitadel:v4.19.4 + command: start-from-init --masterkeyFromEnv --tlsMode disabled + depends_on: + zitadel-db: + condition: service_healthy + environment: + # exactly 32 characters, cannot be changed after the first start + ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY:?set ZITADEL_MASTERKEY (32 characters)} + ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DB_PASSWORD:?set ZITADEL_DB_PASSWORD} + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${ZITADEL_DB_PASSWORD:?set ZITADEL_DB_PASSWORD} + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + # the public address: issuer = http://localhost:8081 + ZITADEL_EXTERNALDOMAIN: localhost + ZITADEL_EXTERNALPORT: 8081 + ZITADEL_EXTERNALSECURE: "false" + # use the login UI built into the zitadel container (the default in v4 is a separate "zitadel-login" container + # behind a reverse proxy, see https://github.com/zitadel/zitadel/tree/main/deploy/compose) + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false" + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: zitadel-admin + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD:?set ZITADEL_ADMIN_PASSWORD} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" + # TEST ONLY - restores SSRF exposure of Actions targets; do not copy to production. + # Targets on private addresses are refused by default; the e-mail target below lives in this network + ZITADEL_HTTPCLIENT_DENYLIST: "169.254.0.0/16" + ports: + - "127.0.0.1:8081:8080" + + # Zitadel Actions v2 target that adds email, email_verified and scope to access tokens + # (endpoint for the target: http://zitadel-email-target:8090/) + zitadel-email-target: + image: node:22-alpine + command: node /app/zitadel-email-target.js + environment: + PORT: 8090 + GRANDNODE_SCOPE: grandnode.api + # project role key and the id of the GrandNode project (set in .env after creating the project in Zitadel) + GRANDNODE_ROLE: grandnode.api + GRANDNODE_PROJECT_ID: ${GRANDNODE_PROJECT_ID:-} + volumes: + - ./zitadel-email-target.js:/app/zitadel-email-target.js:ro + +volumes: + zitadel-db-data: diff --git a/docs/oidc/zitadel-email-target.js b/docs/oidc/zitadel-email-target.js new file mode 100644 index 0000000000..b50ddddc26 --- /dev/null +++ b/docs/oidc/zitadel-email-target.js @@ -0,0 +1,63 @@ +// Zitadel Actions v2 target for the "preaccesstoken" function (test environment helper, Node 18+, no dependencies). +// +// Zitadel puts neither `email`/`email_verified` nor a `scope` claim into JWT access tokens. The GrandNode resource +// server links a user on first use by the verified e-mail address and checks RequiredScopes, so this target appends: +// email, email_verified - from the payload Zitadel sends (userinfo, falling back to the human user) +// scope - GRANDNODE_SCOPE (default "grandnode.api") ONLY when the user is entitled: the user has a +// project role GRANDNODE_ROLE (default "grandnode.api") in the project GRANDNODE_PROJECT_ID +// (user_grants of the payload). Not entitled: no scope claim, so GrandNode answers 403. +// Zitadel sends user_grants only when the project has "Assert Roles on Authentication" +// on (or the client requests urn:zitadel:iam:org:projects:roles). +// ID tokens never pass through this function, so they never carry the scope. +// GRANDNODE_PROJECT_ID is required; without it no scope is ever added. +// Zitadel calls it as a "Call" target (the response is used): see docs/oidc-resource-server.md. +// Listens on PORT (default 8090). Signature verification (ZITADEL-Signature header) is NOT done: test use only. +const http = require('node:http'); + +const scope = process.env.GRANDNODE_SCOPE || 'grandnode.api'; +const role = process.env.GRANDNODE_ROLE || 'grandnode.api'; +const projectId = process.env.GRANDNODE_PROJECT_ID || ''; +const port = Number(process.env.PORT || 8090); + +function isEntitled(payload) { + if (!projectId) return false; + const grants = Array.isArray(payload.user_grants) ? payload.user_grants : []; + return grants.some((g) => g && (g.projectId === projectId || g.project_id === projectId) && + Array.isArray(g.roles) && g.roles.includes(role)); +} + +function claimsFor(payload) { + const info = payload.userinfo || {}; + const human = (payload.user && payload.user.human) || {}; + const email = info.email || (human.email && (human.email.email || human.email)) || ''; + // Zitadel v4 sends user.human.email as a string and the flag as user.human.is_email_verified + const verifiedRaw = info.email_verified !== undefined + ? info.email_verified + : human.is_email_verified ?? (human.email && (human.email.is_verified ?? human.email.isVerified)); + const claims = []; + if (isEntitled(payload)) claims.push({ key: 'scope', value: scope }); + if (typeof email === 'string' && email) { + claims.push({ key: 'email', value: email }); + claims.push({ key: 'email_verified', value: verifiedRaw === true }); + } + return { append_claims: claims }; +} + +if (require.main === module) { + http.createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(claimsFor(payload))); + } catch (e) { + res.writeHead(400); + res.end(); + } + }); + }).listen(port, () => console.log(`zitadel-email-target listening on ${port}`)); +} + +module.exports = { claimsFor }; diff --git a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs index 3c0b146b42..97e590f138 100644 --- a/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Authentication/Services/OidcCustomerResolver.cs @@ -7,6 +7,7 @@ using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Utilities.Customers; using Grand.Data; +using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Permissions; using Grand.Infrastructure.Configuration; @@ -20,7 +21,7 @@ namespace Grand.Business.Authentication.Services; /// -/// Maps a validated access token to a customer: stored subject link; customer with the verified e-mail (link +/// 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, /// and one customer holds at most one identity (enforced by the link's identifier, see ). /// With per-store identity a subject has one link per store (plus the link of a customer of no store, valid in @@ -107,14 +108,12 @@ public virtual async Task Resolve(ClaimsPrincipal principal, //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 - var otherLink = await _externalAuthenticationRepository.GetOneAsync(x => - x.ProviderSystemName == ProviderSystemName && x.CustomerId == customer.Id && x.ExternalIdentifier != sub); - if (otherLink != null) - return LinkedToOtherIdentity(customer, sub, otherLink); + if (await HasOtherLink(customer, sub)) + return LinkedToOtherIdentity(customer, sub); var (linked, other) = await LinkOrGetWinner(customer, sub, email, storeId); if (other != null) - return LinkedToOtherIdentity(customer, sub, other); + return LinkedToOtherIdentity(customer, sub); if (linked?.Id != customer.Id) return await Usable(linked, sub, storeId); @@ -123,6 +122,80 @@ public virtual async Task Resolve(ClaimsPrincipal principal, return result; } + public virtual async Task ResolveForLogin(string sub, string email, bool emailVerified, string storeId) + { + if (string.IsNullOrEmpty(sub)) + return OidcLoginResult.Refused(OidcLoginRefusal.InvalidIdentity); + + var link = await GetLink(sub, storeId); + if (link != null) + { + 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 (string.IsNullOrWhiteSpace(email)) + return OidcLoginResult.Refused(OidcLoginRefusal.InvalidIdentity); + + var customer = await _customerService.GetCustomerByEmail(email, + _customerConfig.RegisterCustomersPerStore ? storeId : ""); + if (customer == null) + { + var customerSettings = await _settingService.LoadSetting(storeId); + if (customerSettings.UserRegistrationType == UserRegistrationType.Disabled) + { + _logger.LogWarning("Sign-in subject {Subject}: registration is disabled in store {StoreId}", sub, storeId); + return OidcLoginResult.Refused(OidcLoginRefusal.RegistrationDisabled); + } + + return OidcLoginResult.RegistrationRequired(); + } + + var usable = await Check(customer, sub, storeId); + if (usable != Unusable.None) + return OidcLoginResult.Refused(ToRefusal(usable)); + + if (await HasOtherLink(customer, sub)) + { + _logger.LogWarning("Sign-in subject {Subject}: customer {CustomerId} is linked to another identity", sub, customer.Id); + return OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity); + } + + //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)) + return OidcLoginResult.LinkRequired(customer); + + return await LinkIdentity(customer, sub, email, storeId) switch { + OidcLinkResult.Linked => OidcLoginResult.SignIn(customer), + _ => OidcLoginResult.Refused(OidcLoginRefusal.LinkedToOtherIdentity) + }; + } + + public virtual async Task LinkIdentity(Customer customer, string sub, string email, string storeId) + { + 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 (await HasOtherLink(customer, sub)) + return OidcLinkResult.CustomerLinkedToOtherSubject; + + var (winner, other) = await LinkOrGetWinner(customer, sub, email ?? customer.Email ?? "", storeId); + if (other != null) + return OidcLinkResult.CustomerLinkedToOtherSubject; + if (winner?.Id != customer.Id) + return OidcLinkResult.SubjectLinkedToOtherCustomer; + + _logger.LogInformation("Subject {Subject} linked to customer {CustomerId}", sub, customer.Id); + return OidcLinkResult.Linked; + } + private async Task Register(string sub, string email, string storeId) { if (!_config.AutoRegister) @@ -169,7 +242,7 @@ await _customerManagerService.RegisterCustomer(new RegistrationRequest(customer, { //lost the race: drop the account just created and use the one the winner linked await _customerService.DeleteCustomer(customer, true); - return other != null ? LinkedToOtherIdentity(customer, sub, other) : await Usable(linked, sub, storeId); + return other != null ? LinkedToOtherIdentity(customer, sub) : await Usable(linked, sub, storeId); } //only after the link is stored, so the loser of a race sends no welcome message @@ -220,10 +293,10 @@ private async Task GetLink(string sub, string storeId) private string LinkStoreId(Customer customer) => _customerConfig.RegisterCustomersPerStore ? customer.StoreId ?? "" : ""; - private OidcCustomerResult LinkedToOtherIdentity(Customer customer, string sub, ExternalAuthentication otherLink) + private OidcCustomerResult LinkedToOtherIdentity(Customer customer, string sub) { - _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to subject {LinkedSubject}", - sub, customer.Id, otherLink.ExternalIdentifier); + _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is already linked to another subject", + sub, customer.Id); return OidcCustomerResult.Forbidden("The customer account is linked to another identity"); } @@ -281,38 +354,63 @@ public static string OidcLinkId(string customerId) /// The checks every other login path applies: an active, registered, non-system account, and with per-store /// identity one of the request's store (or of no store) /// - private async Task Usable(Customer customer, string sub, string storeId) + private enum Unusable { None, Missing, Inactive, Unusable, OtherStore } + + //the checks every other login path applies: an active, registered, non-system account, and with per-store + //identity one of the request's store (or of no store) + private async Task Check(Customer customer, string sub, string storeId) { if (customer == null) { - _logger.LogWarning("Access token subject {Subject} is linked to a customer that no longer exists", sub); - return OidcCustomerResult.Forbidden("The customer account no longer exists"); + _logger.LogWarning("Subject {Subject} is linked to a customer that no longer exists", sub); + return Unusable.Missing; } if (!customer.Active || customer.Deleted) { - _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is inactive or deleted", sub, customer.Id); - return OidcCustomerResult.Forbidden("The customer account is inactive"); + _logger.LogWarning("Subject {Subject}: customer {CustomerId} is inactive or deleted", sub, customer.Id); + return customer.Deleted ? Unusable.Unusable : Unusable.Inactive; } if (customer.IsSystemAccount() || !await _groupService.IsRegistered(customer)) { - _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} is a system or non-registered account", - sub, customer.Id); - return OidcCustomerResult.Forbidden("The customer account cannot be used"); + _logger.LogWarning("Subject {Subject}: customer {CustomerId} is a system or non-registered account", sub, customer.Id); + return Unusable.Unusable; } - if (_customerConfig.RegisterCustomersPerStore && !string.IsNullOrEmpty(customer.StoreId) && - customer.StoreId != storeId) + if (_customerConfig.RegisterCustomersPerStore && !string.IsNullOrEmpty(customer.StoreId) && customer.StoreId != storeId) { - _logger.LogWarning("Access token subject {Subject}: customer {CustomerId} belongs to store {CustomerStoreId}, not {StoreId}", + _logger.LogWarning("Subject {Subject}: customer {CustomerId} belongs to store {CustomerStoreId}, not {StoreId}", sub, customer.Id, customer.StoreId, storeId); - return OidcCustomerResult.Forbidden("The account belongs to another store"); + return Unusable.OtherStore; } - return OidcCustomerResult.Success(customer); + return Unusable.None; } + private async Task Usable(Customer customer, string sub, string storeId) => + await Check(customer, sub, storeId) switch { + Unusable.None => OidcCustomerResult.Success(customer), + Unusable.Missing => OidcCustomerResult.Forbidden("The customer account no longer exists"), + Unusable.Inactive => OidcCustomerResult.Forbidden("The customer account is inactive"), + Unusable.OtherStore => OidcCustomerResult.Forbidden("The account belongs to another store"), + _ => customer.Deleted + ? OidcCustomerResult.Forbidden("The customer account is inactive") + : OidcCustomerResult.Forbidden("The customer account cannot be used") + }; + + private static OidcLoginRefusal ToRefusal(Unusable reason) => + reason == Unusable.Inactive ? OidcLoginRefusal.AccountInactive : OidcLoginRefusal.AccountUnusable; + + private async Task HasOtherLink(Customer customer, string sub) => + await _externalAuthenticationRepository.GetOneAsync(x => + x.ProviderSystemName == ProviderSystemName && x.CustomerId == customer.Id && x.ExternalIdentifier != sub) != null; + + //the same condition the password sign-in uses to ask for the second factor + private async Task HasTwoFactor(Customer customer, string storeId) => + customer.GetUserFieldFromEntity(SystemCustomerFieldNames.TwoFactorEnabled) && + (await _settingService.LoadSetting(storeId)).TwoFactorAuthenticationEnabled; + private async Task IsBackOfficeAccount(Customer customer) => !string.IsNullOrEmpty(customer.VendorId) || !string.IsNullOrEmpty(customer.StaffStoreId) || await _permissionService.Authorize(StandardPermission.ManageAccessAdminPanel, customer); diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs index 610c48f722..01dcd7b3b1 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IJwtBearerAuthenticationService.cs @@ -2,7 +2,7 @@ namespace Grand.Business.Core.Interfaces.Authentication; -[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")] +[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")] public interface IJwtBearerAuthenticationService { /// diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs index 74aad3ecc3..532648bcd2 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IOidcCustomerResolver.cs @@ -1,10 +1,11 @@ using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; using System.Security.Claims; namespace Grand.Business.Core.Interfaces.Authentication; /// -/// Maps a validated access token of the identity provider to a customer: by the stored link to the token +/// Maps a validated access token or a browser sign-in of the identity provider to a customer: by the stored link to the token /// subject, on first use by the verified e-mail address, otherwise by registering one /// public interface IOidcCustomerResolver @@ -12,4 +13,23 @@ public interface IOidcCustomerResolver /// Principal of the validated access token /// Store resolved from the request host Task Resolve(ClaimsPrincipal principal, string storeId); + + /// + /// Browser sign-in: never registers and never links a back-office account or an unverified e-mail by itself + /// + /// Subject of the provider identity + /// E-mail of the provider identity + /// The provider verified the e-mail + /// Current store + Task ResolveForLogin(string sub, string email, bool emailVerified, string storeId); + + /// + /// Stores the link between the provider subject and the customer (one subject per customer, one customer per subject; + /// with per-store identity one customer per subject and store) + /// + /// Customer to link + /// Subject of the provider identity + /// E-mail of the provider identity + /// Current store + Task LinkIdentity(Customer customer, string sub, string email, string storeId); } diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs index 8a39ed82fa..df4a9cea86 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Customers/IUserApiService.cs @@ -3,7 +3,7 @@ namespace Grand.Business.Core.Interfaces.Customers; -[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")] +[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")] public interface IUserApiService { /// diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs new file mode 100644 index 0000000000..42d48b7e85 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Authentication/OidcLoginResult.cs @@ -0,0 +1,56 @@ +using Grand.Domain.Customers; + +namespace Grand.Business.Core.Utilities.Authentication; + +public enum OidcLoginOutcome +{ + SignIn, + RegistrationRequired, + LinkRequired, + Refused +} + +/// +/// Why a browser sign-in was refused; the login page picks a fixed message by it +/// +public enum OidcLoginRefusal +{ + None, + InvalidIdentity, + AccountInactive, + AccountUnusable, + LinkedToOtherIdentity, + RegistrationDisabled +} + +public enum OidcLinkResult +{ + Linked, + SubjectLinkedToOtherCustomer, + CustomerLinkedToOtherSubject +} + +/// +/// Outcome of mapping a provider identity to a customer during a browser sign-in +/// +public class OidcLoginResult +{ + private OidcLoginResult(OidcLoginOutcome outcome, Customer customer, OidcLoginRefusal refusal) + { + Outcome = outcome; + Customer = customer; + Refusal = refusal; + } + + public OidcLoginOutcome Outcome { get; } + + /// The customer to sign in (SignIn) or the account to connect (LinkRequired) + public Customer Customer { get; } + + public OidcLoginRefusal Refusal { get; } + + public static OidcLoginResult SignIn(Customer customer) => new(OidcLoginOutcome.SignIn, customer, OidcLoginRefusal.None); + public static OidcLoginResult RegistrationRequired() => new(OidcLoginOutcome.RegistrationRequired, null, OidcLoginRefusal.None); + public static OidcLoginResult LinkRequired(Customer customer) => new(OidcLoginOutcome.LinkRequired, customer, OidcLoginRefusal.None); + public static OidcLoginResult Refused(OidcLoginRefusal refusal) => new(OidcLoginOutcome.Refused, null, refusal); +} diff --git a/src/Core/Grand.Domain/Customers/UserApi.cs b/src/Core/Grand.Domain/Customers/UserApi.cs index 510d3ae312..8f2fa7d1d5 100644 --- a/src/Core/Grand.Domain/Customers/UserApi.cs +++ b/src/Core/Grand.Domain/Customers/UserApi.cs @@ -1,6 +1,6 @@ namespace Grand.Domain.Customers; -[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider")] +[Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")] public class UserApi : BaseEntity { public string Email { get; set; } diff --git a/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs b/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs index ee5f436f93..788aae87c2 100644 --- a/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs +++ b/src/Core/Grand.Infrastructure/Configuration/OidcProtectedResource.cs @@ -11,7 +11,7 @@ public class OidcProtectedResource /// Path prefix, matched case-insensitively on a segment boundary ("/api" covers "/api/x", not "/apix") public string PathPrefix { get; set; } - /// Canonical resource URI (RFC 8707); empty = the request's base URL + PathPrefix + /// Canonical resource URI (RFC 8707); empty = the Store URL of the request's store + PathPrefix public string Resource { get; set; } /// Additional accepted "aud" values, for providers that do not put the resource URI there diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs new file mode 100644 index 0000000000..7e76798bff --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/OidcCustomerResolverLoginTests.cs @@ -0,0 +1,432 @@ +using Grand.Business.Authentication.Services; +using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; +using System.Security.Claims; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class OidcCustomerResolverLoginTests +{ + private IRepository _links; + private Mock _customerService; + private Mock _customerManagerService; + private Mock _settingService; + private Mock _groupService; + private Mock _permissionService; + private Mock _mediator; + private OidcResourceServerConfig _config; + private CustomerConfig _customerConfig; + private CustomerSettings _customerSettings; + + [TestInitialize] + public void Init() + { + _links = new MongoDBRepositoryTest(); + _customerService = new Mock(); + _customerManagerService = new Mock(); + _settingService = new Mock(); + _groupService = new Mock(); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(true); + _permissionService = new Mock(); + _mediator = new Mock(); + _config = new OidcResourceServerConfig { Enabled = true }; + _customerConfig = new CustomerConfig(); + _customerSettings = new CustomerSettings { UserRegistrationType = UserRegistrationType.Standard }; + _settingService.Setup(x => x.LoadSetting(It.IsAny())).ReturnsAsync(_customerSettings); + _customerService.Setup(x => x.InsertGuestCustomer(It.IsAny())) + .ReturnsAsync((Customer c) => { c.Id = "new1"; return c; }); + } + + private OidcCustomerResolver Resolver(IRepository links = null) => + new(links ?? _links, _customerService.Object, _customerManagerService.Object, _settingService.Object, + _groupService.Object, _permissionService.Object, _mediator.Object, _config, _customerConfig, NullLogger.Instance); + + private Task Link(string customerId, string sub = "sub-1", string provider = OidcCustomerResolver.ProviderSystemName, + string storeId = null) => + _links.InsertAsync(new ExternalAuthentication { + CustomerId = customerId, ProviderSystemName = provider, ExternalIdentifier = sub, StoreId = storeId + }); + + private List LinksOf(string sub) => + _links.Table.Where(x => x.ExternalIdentifier == sub).ToList(); + + + private const string Store = "store1"; + + [TestMethod] + public async Task NoSubject_Refused_InvalidIdentity() => + Assert.AreEqual(OidcLoginRefusal.InvalidIdentity, (await Resolver().ResolveForLogin(null, "a@b.c", true, Store)).Refusal); + + [TestMethod] + public async Task Linked_SignIn_WithoutEmail() + { + var customer = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(customer); + await Link("c1"); + var result = await Resolver().ResolveForLogin("sub-1", null, false, Store); + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + Assert.AreSame(customer, result.Customer); + } + + [TestMethod] + public async Task LinkedInactive_Refused_AccountInactive() + { + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = false }); + await Link("c1"); + Assert.AreEqual(OidcLoginRefusal.AccountInactive, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Refusal); + } + + [TestMethod] + public async Task NoEmail_Refused_InvalidIdentity() => + Assert.AreEqual(OidcLoginRefusal.InvalidIdentity, (await Resolver().ResolveForLogin("sub-1", " ", true, Store)).Refusal); + + [TestMethod] + public async Task ExistingCustomer_Verified_LinkedAndSignIn() + { + var customer = new Customer { Id = "c1", Active = true, Email = "john@example.com" }; + _customerService.Setup(x => x.GetCustomerByEmail("John@Example.com", "")).ReturnsAsync(customer); + var result = await Resolver().ResolveForLogin("sub-1", "John@Example.com", true, Store); + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + var link = await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1"); + Assert.AreEqual("c1", link.CustomerId); + Assert.AreEqual(OidcCustomerResolver.ProviderSystemName, link.ProviderSystemName); + } + + [TestMethod] + public async Task ExistingCustomer_Unverified_LinkRequired_NotLinked() + { + var customer = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", false, Store); + Assert.AreEqual(OidcLoginOutcome.LinkRequired, result.Outcome); + Assert.AreSame(customer, result.Customer); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task ExistingAdmin_Verified_LinkRequired_NotLinked() + { + var customer = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + _permissionService.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, customer)).ReturnsAsync(true); + Assert.AreEqual(OidcLoginOutcome.LinkRequired, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + [DataRow("v1", null)] + [DataRow(null, "s1")] + public async Task ExistingVendorOrStoreStaff_Verified_LinkRequired(string vendorId, string staffStoreId) + { + var customer = new Customer { Id = "c1", Active = true, VendorId = vendorId, StaffStoreId = staffStoreId }; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + Assert.AreEqual(OidcLoginOutcome.LinkRequired, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + private static Customer TwoFactorCustomer() + { + var customer = new Customer { Id = "c1", Active = true }; + customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.TwoFactorEnabled, Value = "True", StoreId = "" }); + return customer; + } + + [TestMethod] + public async Task ExistingTwoFactorCustomer_Verified_LinkRequired_NotLinked() + { + _customerSettings.TwoFactorAuthenticationEnabled = true; + var customer = TwoFactorCustomer(); + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(customer); + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store); + Assert.AreEqual(OidcLoginOutcome.LinkRequired, result.Outcome); + Assert.AreSame(customer, result.Customer); + Assert.IsNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task ExistingTwoFactorCustomer_StoreTwoFactorOff_Verified_SignIn() + { + _customerSettings.TwoFactorAuthenticationEnabled = false; + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())).ReturnsAsync(TwoFactorCustomer()); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + Assert.IsNotNull(await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1")); + } + + [TestMethod] + public async Task LinkedTwoFactorCustomer_SignIn() + { + _customerSettings.TwoFactorAuthenticationEnabled = true; + var customer = TwoFactorCustomer(); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(customer); + await Link("c1"); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + } + + [TestMethod] + public async Task LinkedAdmin_SignIn() + { + var customer = new Customer { Id = "c1", Active = true }; + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(customer); + _permissionService.Setup(x => x.Authorize(StandardPermission.ManageAccessAdminPanel, customer)).ReturnsAsync(true); + await Link("c1"); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + } + + [TestMethod] + public async Task ExistingCustomerLinkedToOtherSubject_Refused() + { + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Customer { Id = "c1", Active = true }); + await Link("c1", "other-sub"); + Assert.AreEqual(OidcLoginRefusal.LinkedToOtherIdentity, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Refusal); + } + + [TestMethod] + public async Task ExistingGuestOrSystem_Refused_AccountUnusable() + { + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Customer { Id = "c1", Active = true }); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(false); + Assert.AreEqual(OidcLoginRefusal.AccountUnusable, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Refusal); + } + + [TestMethod] + public async Task PerStore_EmailLookupScopedToStore() + { + _customerConfig.RegisterCustomersPerStore = true; + await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store); + _customerService.Verify(x => x.GetCustomerByEmail("a@b.c", Store), Times.Once); + } + + [TestMethod] + public async Task NoCustomer_RegistrationRequired_NothingCreated_EvenWithAutoRegister() + { + _config.AutoRegister = true; + Assert.AreEqual(OidcLoginOutcome.RegistrationRequired, (await Resolver().ResolveForLogin("sub-1", "a@b.c", false, Store)).Outcome); + _customerService.Verify(x => x.InsertGuestCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task NoCustomer_RegistrationDisabled_Refused() + { + _customerSettings.UserRegistrationType = UserRegistrationType.Disabled; + Assert.AreEqual(OidcLoginRefusal.RegistrationDisabled, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Refusal); + } + + [TestMethod] + public async Task LinkIdentity_StoresLink() + { + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "A@B.c", Store)); + var link = await _links.GetOneAsync(x => x.ExternalIdentifier == "sub-1"); + Assert.AreEqual("c1", link.CustomerId); + Assert.AreEqual("a@b.c", link.Email); + } + + [TestMethod] + public async Task LinkIdentity_SameCustomerTwice_Linked() + { + await Link("c1"); + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + } + + [TestMethod] + public async Task LinkIdentity_SubjectOfOtherCustomer_Refused() + { + await Link("c2"); + Assert.AreEqual(OidcLinkResult.SubjectLinkedToOtherCustomer, + await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + } + + [TestMethod] + public async Task LinkIdentity_CustomerHasOtherSubject_Refused() + { + await Link("c1", "other-sub"); + Assert.AreEqual(OidcLinkResult.CustomerLinkedToOtherSubject, + await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + } + + [TestMethod] + public async Task LinkIdentity_LostRace_ReturnsSubjectLinkedToOtherCustomer() + { + var links = new Mock>(); + links.SetupSequence(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((ExternalAuthentication)null) //subject not linked + .ReturnsAsync((ExternalAuthentication)null) //customer has no other link + .ReturnsAsync(new ExternalAuthentication { CustomerId = "winner", ExternalIdentifier = "sub-1" }); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("duplicate")); + Assert.AreEqual(OidcLinkResult.SubjectLinkedToOtherCustomer, + await Resolver(links.Object).LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + } + + [TestMethod] + public async Task ResolveForLogin_PerStore_SubjectLinkedInAnotherStore_SignsInThisStoresCustomer() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: "store2"); + var customer = new Customer { Id = "c1", Active = true, StoreId = Store }; + _customerService.Setup(x => x.GetCustomerByEmail("a@b.c", Store)).ReturnsAsync(customer); + + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store); + + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + Assert.AreEqual("c1", result.Customer.Id); + Assert.AreEqual(Store, LinksOf("sub-1").Single(x => x.CustomerId == "c1").StoreId); + _customerService.Verify(x => x.GetCustomerById("c9"), Times.Never); + } + + [TestMethod] + public async Task ResolveForLogin_PerStore_GlobalCustomerLink_SignsInInEveryStore() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c1", storeId: ""); + _customerService.Setup(x => x.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = "" }); + + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store)).Outcome); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, "store2")).Outcome); + } + + [TestMethod] + public async Task LinkIdentity_PerStore_SubjectLinkedInAnotherStore_LinksThisStore() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: "store2"); + + Assert.AreEqual(OidcLinkResult.Linked, + await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = Store }, "sub-1", "a@b.c", Store)); + Assert.AreEqual(Store, LinksOf("sub-1").Single(x => x.CustomerId == "c1").StoreId); + } + + [TestMethod] + public async Task LinkIdentity_PerStore_GlobalCustomer_SubjectLinkedInThisStore_Refused() + { + //the store's link takes precedence in this store, so a link of the global account would never be used here + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: Store); + + Assert.AreEqual(OidcLinkResult.SubjectLinkedToOtherCustomer, + await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = "" }, "sub-1", "a@b.c", Store)); + Assert.HasCount(1, LinksOf("sub-1")); + } + + [TestMethod] + public async Task LinkIdentity_NotPerStore_LinkWithoutStore() + { + Assert.AreEqual(OidcLinkResult.Linked, + await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = Store }, "sub-1", "a@b.c", Store)); + Assert.AreEqual("", LinksOf("sub-1").Single().StoreId); + } + + [TestMethod] + public async Task ResolveForLogin_PerStore_LegacyGlobalLinkToOtherStoreCustomer_LinksThisStoresCustomer() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: ""); + _customerService.Setup(x => x.GetCustomerById("c9")).ReturnsAsync(new Customer { Id = "c9", Active = true, StoreId = "store2" }); + _customerService.Setup(x => x.GetCustomerByEmail("a@b.c", Store)).ReturnsAsync(new Customer { Id = "c1", Active = true, StoreId = Store }); + + var result = await Resolver().ResolveForLogin("sub-1", "a@b.c", true, Store); + + Assert.AreEqual(OidcLoginOutcome.SignIn, result.Outcome); + Assert.AreEqual("c1", result.Customer.Id); + Assert.AreEqual(Store, LinksOf("sub-1").Single(x => x.CustomerId == "c1").StoreId); + Assert.AreEqual(OidcLoginOutcome.SignIn, (await Resolver().ResolveForLogin("sub-1", "a@b.c", true, "store2")).Outcome); + } + + [TestMethod] + public async Task LinkIdentity_PerStore_LegacyGlobalLinkToOtherStoreCustomer_LinksThisStore() + { + _customerConfig.RegisterCustomersPerStore = true; + await Link("c9", storeId: null); + _customerService.Setup(x => x.GetCustomerById("c9")).ReturnsAsync(new Customer { Id = "c9", Active = true, StoreId = "store2" }); + + Assert.AreEqual(OidcLinkResult.Linked, + await Resolver().LinkIdentity(new Customer { Id = "c1", StoreId = Store }, "sub-1", "a@b.c", Store)); + Assert.HasCount(2, LinksOf("sub-1")); + } + /// + /// Repository that lets a concurrent request store its record right before this one's insert + /// + private sealed class RacingLinks : MongoDBRepositoryTest + { + public Func BeforeInsert { get; set; } + + public override async Task InsertAsync(ExternalAuthentication entity) + { + var before = BeforeInsert; + BeforeInsert = null; + if (before != null) + await before(); + return await base.InsertAsync(entity); + } + } + + [TestMethod] + public async Task LinkIdentity_StoresLinkWithCustomerDerivedId() + { + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + Assert.AreEqual(OidcCustomerResolver.OidcLinkId("c1"), LinksOf("sub-1").Single().Id); + } + + [TestMethod] + public async Task LinkIdentity_LostRaceToOtherSubjectOfTheCustomer_CustomerLinkedToOtherSubject() + { + var links = new Mock>(); + links.Setup(x => x.GetOneAsync(It.IsAny>>())) + .ReturnsAsync((ExternalAuthentication)null); + links.Setup(x => x.InsertAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("duplicate")); + links.Setup(x => x.GetByIdAsync(OidcCustomerResolver.OidcLinkId("c1"))).ReturnsAsync(new ExternalAuthentication { + Id = OidcCustomerResolver.OidcLinkId("c1"), CustomerId = "c1", + ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "other-sub" + }); + + Assert.AreEqual(OidcLinkResult.CustomerLinkedToOtherSubject, + await Resolver(links.Object).LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + } + + [TestMethod] + public async Task ResolveForLogin_LostRaceToOtherSubjectOfTheCustomer_RefusedLinkedToOtherIdentity() + { + var links = new RacingLinks(); + links.BeforeInsert = () => links.InsertAsync(new ExternalAuthentication { + Id = OidcCustomerResolver.OidcLinkId("c1"), CustomerId = "c1", + ProviderSystemName = OidcCustomerResolver.ProviderSystemName, ExternalIdentifier = "other-sub", StoreId = "" + }); + _customerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Customer { Id = "c1", Active = true }); + + var result = await Resolver(links).ResolveForLogin("sub-1", "a@b.c", true, Store); + + Assert.AreEqual(OidcLoginRefusal.LinkedToOtherIdentity, result.Refusal); + var stored = links.Table.Where(x => x.CustomerId == "c1").ToList(); + Assert.HasCount(1, stored); + Assert.AreEqual("other-sub", stored.Single().ExternalIdentifier); + } + + [TestMethod] + public async Task LinkIdentity_AfterUnlink_RelinksWithTheSameId() + { + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-1", "a@b.c", Store)); + var first = LinksOf("sub-1").Single(); + await _links.DeleteAsync(first); + + Assert.AreEqual(OidcLinkResult.Linked, await Resolver().LinkIdentity(new Customer { Id = "c1" }, "sub-2", "a@b.c", Store)); + Assert.AreEqual(first.Id, LinksOf("sub-2").Single().Id); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs new file mode 100644 index 0000000000..bd1f4ff59c --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationContextTests.cs @@ -0,0 +1,92 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Web.Common.Security.ExternalRegistration; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Security.Claims; + +namespace Grand.Web.Common.Tests.Security.ExternalRegistration; + +[TestClass] +public class ExternalRegistrationContextTests +{ + private const string Scheme = "ExternalAuthentication"; //GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme + private Mock _auth; + private Mock _resolver; + private DefaultHttpContext _http; + + private static readonly ExternalRegistrationIdentity Identity = + new(OidcResourceServerDefaults.ProviderSystemName, "sub-1", "john@example.com", true, "John", "Doe"); + + [TestInitialize] + public void Init() + { + _auth = new Mock(); + _resolver = new Mock(); + _http = new DefaultHttpContext { + RequestServices = new ServiceCollection().AddSingleton(_auth.Object).BuildServiceProvider() + }; + } + + private ExternalRegistrationContext Context() => + new(new HttpContextAccessor { HttpContext = _http }, _resolver.Object, NullLogger.Instance); + + private void Cookie(ClaimsPrincipal principal) => + _auth.Setup(x => x.AuthenticateAsync(_http, Scheme)).ReturnsAsync(principal == null + ? AuthenticateResult.NoResult() + : AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme))); + + [TestMethod] + public async Task RoundTrip() + { + Cookie(ExternalRegistrationContext.CreatePrincipal(Identity)); + Assert.AreEqual(Identity, await Context().GetPending()); + } + + [TestMethod] + public async Task NoCookie_Null() + { + Cookie(null); + Assert.IsNull(await Context().GetPending()); + } + + [TestMethod] + public async Task OtherExternalLogin_Null() + { + //e.g. the Google handler signs its own principal into the same cookie + Cookie(new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Email, "x@y.z")], "Google"))); + Assert.IsNull(await Context().GetPending()); + } + + [TestMethod] + public async Task UnverifiedFlag_RoundTrips() + { + Cookie(ExternalRegistrationContext.CreatePrincipal(Identity with { EmailVerified = false })); + Assert.IsFalse((await Context().GetPending()).EmailVerified); + } + + [TestMethod] + public async Task Complete_Links_AndClears() + { + Cookie(ExternalRegistrationContext.CreatePrincipal(Identity)); + var customer = new Customer { Id = "c1" }; + _resolver.Setup(x => x.LinkIdentity(customer, "sub-1", "john@example.com", "store1")).ReturnsAsync(OidcLinkResult.Linked); + Assert.AreEqual(OidcLinkResult.Linked, await Context().Complete(customer, "store1")); + _auth.Verify(x => x.SignOutAsync(_http, Scheme, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Complete_UnknownProvider_NotLinked_Cleared() + { + Cookie(ExternalRegistrationContext.CreatePrincipal(Identity with { ProviderSystemName = "Other" })); + await Assert.ThrowsExactlyAsync(() => Context().Complete(new Customer { Id = "c1" }, "store1")); + _resolver.Verify(x => x.LinkIdentity(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _auth.Verify(x => x.SignOutAsync(_http, Scheme, It.IsAny()), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs new file mode 100644 index 0000000000..f43ce1ded2 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ExternalRegistrationPolicyTests.cs @@ -0,0 +1,29 @@ +using Grand.Domain.Customers; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.Security.ExternalRegistration; + +[TestClass] +public class ExternalRegistrationPolicyTests +{ + [TestMethod] + [DataRow(UserRegistrationType.Standard, true, UserRegistrationType.Standard)] + [DataRow(UserRegistrationType.EmailValidation, true, UserRegistrationType.Standard)] + [DataRow(UserRegistrationType.AdminApproval, true, UserRegistrationType.AdminApproval)] + [DataRow(UserRegistrationType.Standard, false, UserRegistrationType.EmailValidation)] + [DataRow(UserRegistrationType.EmailValidation, false, UserRegistrationType.EmailValidation)] + [DataRow(UserRegistrationType.AdminApproval, false, UserRegistrationType.AdminApproval)] + [DataRow(UserRegistrationType.Disabled, true, UserRegistrationType.Disabled)] + public void EffectiveType(UserRegistrationType store, bool verified, UserRegistrationType expected) => + Assert.AreEqual(expected, ExternalRegistrationPolicy.EffectiveRegistrationType(store, verified)); + + [TestMethod] + public void ConnectsAtRegistration_OnlyVerifiedEmail() + { + var identity = new ExternalRegistrationIdentity("p", "sub", "a@b.c", true, null, null); + Assert.IsTrue(ExternalRegistrationPolicy.ConnectsAtRegistration(identity)); + Assert.IsFalse(ExternalRegistrationPolicy.ConnectsAtRegistration(identity with { EmailVerified = false })); + Assert.IsFalse(ExternalRegistrationPolicy.ConnectsAtRegistration(null)); + } +} diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs new file mode 100644 index 0000000000..ac97cd44db --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs @@ -0,0 +1,169 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Common.Security.ExternalRegistration; +using Grand.Web.Controllers; +using Grand.Web.Features.Models.Customers; +using Grand.Web.Models.Customer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class AccountControllerExternalRegisterTests +{ + private static readonly ExternalRegistrationIdentity Pending = + new("ExternalAuth.OpenIdConnect", "sub-1", "john@example.com", true, "John", "Doe"); + + private Mock _manager; + private Mock _messages; + private Mock _external; + private Mock _externalAuth; + private CustomerSettings _settings; + private AccountController _controller; + private readonly Customer _guest = new() { Id = "g1" }; + + [TestInitialize] + public void Init() + { + _manager = new Mock(); + _messages = new Mock(); + _external = new Mock(); + _externalAuth = new Mock(); + _settings = new CustomerSettings { UserRegistrationType = UserRegistrationType.Standard }; + + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(_guest); + work.Setup(w => w.WorkingLanguage).Returns(new Language { Id = "l1" }); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "s1" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(store.Object); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new RegisterModel()); + var groups = new Mock(); + groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(false); + + _controller = new AccountController(new Mock().Object, + new Mock().Object, context.Object, new Mock().Object, + groups.Object, _manager.Object, new Mock().Object, mediator.Object, _messages.Object, + new CaptchaSettings(), _settings, new CustomerConfig()); + _controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + _controller.TempData = new TempDataDictionary(_controller.HttpContext, new Mock().Object); + var url = new Mock(); + url.Setup(x => x.RouteUrl(It.IsAny())).Returns("/registerresult/1"); + _controller.Url = url.Object; + } + + [TestMethod] + public async Task Get_Pending_PrefillsAndMarksExternal() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object)).Result; + var model = (RegisterModel)view.Model; + Assert.AreEqual("john@example.com", model.Email); + Assert.AreEqual("John", model.FirstName); + Assert.IsTrue(model.ExternalRegistration); + Assert.IsTrue(model.ExternalConnects); + } + + [TestMethod] + public async Task Post_Pending_UsesPendingEmail_RandomPassword_Links() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + RegistrationRequest request = null; + _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); + await _controller.Register(new RegisterModel { Email = "evil@b.c", ExternalRegistration = false }, null, + _external.Object, _externalAuth.Object); + Assert.AreEqual("john@example.com", request.Email); + Assert.IsFalse(string.IsNullOrEmpty(request.Password)); + _external.Verify(x => x.Complete(_guest, "s1"), Times.Once); + } + + [TestMethod] + [DataRow(UserRegistrationType.Standard, true, true)] + [DataRow(UserRegistrationType.EmailValidation, true, true)] + [DataRow(UserRegistrationType.Standard, false, false)] + [DataRow(UserRegistrationType.AdminApproval, true, false)] + public async Task Post_Pending_ApprovalFollowsPolicy(UserRegistrationType type, bool verified, bool approved) + { + _settings.UserRegistrationType = type; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = verified }); + RegistrationRequest request = null; + _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); + await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + Assert.AreEqual(approved, request.IsApproved); + } + + [TestMethod] + public async Task Post_Pending_Unverified_SendsActivation() + { + _settings.UserRegistrationType = UserRegistrationType.Standard; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + _messages.Verify(x => x.SendCustomerEmailValidationMessage(_guest, It.IsAny(), "l1"), Times.Once); + Assert.AreEqual((int)UserRegistrationType.EmailValidation, ((RedirectToRouteResult)result).RouteValues["resultId"]); + } + + [TestMethod] + public async Task Post_Pending_Unverified_TypedPassword_NotConnected() + { + //whoever holds an identity with an unconfirmed address must not keep the account once its owner activates it + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + RegistrationRequest request = null; + _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); + await _controller.Register(new RegisterModel { Password = "Secret1!" }, null, _external.Object, _externalAuth.Object); + Assert.AreEqual("john@example.com", request.Email); + Assert.AreEqual("Secret1!", request.Password); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public async Task Get_Pending_Unverified_AsksForPassword() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object)).Result; + var model = (RegisterModel)view.Model; + Assert.IsTrue(model.ExternalRegistration); + Assert.IsFalse(model.ExternalConnects); + } + + [TestMethod] + public async Task Post_Pending_LinkFails_WarnsCustomer() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.SubjectLinkedToOtherCustomer); + await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + Assert.IsNotNull(_controller.TempData["grand.notifications.Warning"]); + } + + [TestMethod] + public async Task Post_NoPending_RegularPath_NoLink() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + RegistrationRequest request = null; + _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); + await _controller.Register(new RegisterModel { Email = "a@b.c", Password = "Secret1!" }, null, _external.Object, _externalAuth.Object); + Assert.AreEqual("Secret1!", request.Password); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs new file mode 100644 index 0000000000..2782f4efb6 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerTwoFactorReturnUrlTests.cs @@ -0,0 +1,88 @@ +using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Controllers; +using Grand.Web.Models.Customer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class AccountControllerTwoFactorReturnUrlTests +{ + private sealed class TestSession : ISession + { + private readonly Dictionary _data = new(); + public bool IsAvailable => true; + public string Id => "s"; + public IEnumerable Keys => _data.Keys; + public void Clear() => _data.Clear(); + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Remove(string key) => _data.Remove(key); + public void Set(string key, byte[] value) => _data[key] = value; + public bool TryGetValue(string key, out byte[] value) => _data.TryGetValue(key, out value); + } + + private Mock _manager; + private Mock _customers; + private AccountController _controller; + private readonly Customer _customer = new() { Id = "c1", Email = "a@b.c" }; + + [TestInitialize] + public void Init() + { + _manager = new Mock(); + _customers = new Mock(); + _customers.Setup(x => x.GetCustomerByEmail("a@b.c", It.IsAny())).ReturnsAsync(_customer); + var context = new Mock(); + var settings = new CustomerSettings { TwoFactorAuthenticationEnabled = true }; + _controller = new AccountController(new Mock().Object, new Mock().Object, + context.Object, _customers.Object, new Mock().Object, _manager.Object, new Mock().Object, + new Mock().Object, new Mock().Object, new CaptchaSettings(), settings, + new CustomerConfig()); + var http = new DefaultHttpContext { Session = new TestSession() }; + _controller.ControllerContext = new ControllerContext { HttpContext = http }; + var url = new Mock(); + url.Setup(x => x.IsLocalUrl(It.IsAny())).Returns(u => u.StartsWith('/') && !u.StartsWith("//")); + _controller.Url = url.Object; + } + + private async Task PassTwoFactor(string returnUrl) + { + _manager.Setup(x => x.LoginCustomer("a@b.c", "p", It.IsAny())).ReturnsAsync(CustomerLoginResults.RequiresTwoFactor); + await _controller.Login(new LoginModel { Email = "a@b.c", Password = "p" }, returnUrl); + var twoFactor = new Mock(); + twoFactor.Setup(x => x.AuthenticateTwoFactor(It.IsAny(), "123", _customer, It.IsAny())) + .ReturnsAsync(true); + return await _controller.TwoFactorAuthorization("123", twoFactor.Object); + } + + [TestMethod] + public async Task LocalReturnUrl_SurvivesTwoFactor() + { + var result = await PassTwoFactor("/oidc/link/complete"); + Assert.AreEqual("/oidc/link/complete", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task ExternalReturnUrl_NotKept() + { + var result = await PassTwoFactor("https://evil.example/"); + Assert.IsInstanceOfType(result); + Assert.AreEqual("HomePage", ((RedirectToRouteResult)result).RouteName); + } +} diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs index 943686c5ed..63b1949eca 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs @@ -91,7 +91,8 @@ public async Task RegisterValidator_ScopesEmailLookupToCurrentStore() Translation().Object, new Mock().Object, new CustomerSettings(), new Grand.Domain.Common.CaptchaSettings(), new Mock().Object, new Mock().Object, mediator.Object, attrParser.Object, - customerService.Object, groupService.Object, Context(), PerStoreOn().Config); + customerService.Object, groupService.Object, Context(), PerStoreOn().Config, + new Mock().Object); await validator.ValidateAsync(new RegisterModel { Email = "u@x.com" }); diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs new file mode 100644 index 0000000000..09e5d84034 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/RegisterValidatorExternalTests.cs @@ -0,0 +1,99 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Models; +using Grand.Infrastructure.Validators; +using Grand.Mediator; +using Grand.SharedKernel.Captcha; +using Grand.Web.Common.Security.ExternalRegistration; +using Grand.Web.Features.Models.Customers; +using Grand.Web.Models.Customer; +using Grand.Web.Validators.Customer; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Validators.Customers; + +[TestClass] +public class RegisterValidatorExternalTests +{ + private static readonly ExternalRegistrationIdentity Pending = + new("ExternalAuth.OpenIdConnect", "sub-1", "john@example.com", true, "John", "Doe"); + + private Mock _external; + private Mock _customerService; + + [TestInitialize] + public void Init() + { + _external = new Mock(); + _customerService = new Mock(); + } + + private RegisterValidator Validator() + { + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); + var groupService = new Mock(); + groupService.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(false); + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var attrParser = new Mock(); + attrParser.Setup(p => p.GetAttributeWarnings(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List()); + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(new Customer()); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "s1" }); + var ctx = new Mock(); + ctx.Setup(c => c.WorkContext).Returns(work.Object); + ctx.Setup(c => c.StoreContext).Returns(store.Object); + + return new RegisterValidator( + new List>(), + new List>(), + translation.Object, new Mock().Object, new CustomerSettings(), + new CaptchaSettings(), new Mock().Object, + new Mock().Object, mediator.Object, attrParser.Object, + _customerService.Object, groupService.Object, ctx.Object, new CustomerConfig(), _external.Object); + } + + [TestMethod] + public async Task Pending_NoPassword_Valid() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + var result = await Validator().ValidateAsync(new RegisterModel { Email = Pending.Email }); + Assert.IsTrue(result.IsValid, string.Join(";", result.Errors)); + } + + [TestMethod] + public async Task Pending_Unverified_NoPassword_Invalid() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + var result = await Validator().ValidateAsync(new RegisterModel { Email = Pending.Email }); + Assert.IsTrue(result.Errors.Any(e => e.PropertyName == nameof(RegisterModel.Password))); + } + + [TestMethod] + public async Task NoPending_NoPassword_Invalid_EvenWithFormFlag() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + var result = await Validator().ValidateAsync(new RegisterModel { Email = "a@b.c", ExternalRegistration = true }); + Assert.IsTrue(result.Errors.Any(e => e.PropertyName == nameof(RegisterModel.Password))); + } + + [TestMethod] + public async Task Pending_UniquenessCheckedOnPendingEmail() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _customerService.Setup(x => x.GetCustomerByEmail(Pending.Email, It.IsAny())).ReturnsAsync(new Customer()); + var result = await Validator().ValidateAsync(new RegisterModel { Email = "other@b.c" }); + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.Register.Errors.EmailAlreadyExists")); + } +} diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs new file mode 100644 index 0000000000..becdcb1d37 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationContext.cs @@ -0,0 +1,93 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; +using Grand.Web.Common.Security.Oidc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Security.Claims; + +namespace Grand.Web.Common.Security.ExternalRegistration; + +public class ExternalRegistrationContext : IExternalRegistrationContext +{ + /// Authentication type of the pending identity; other external logins in the same cookie are ignored + public const string AuthenticationType = "GrandExternalRegistration"; + + private const string ProviderClaim = "grand_ext_provider"; + private const string SubjectClaim = "grand_ext_sub"; + private const string EmailClaim = "grand_ext_email"; + private const string EmailVerifiedClaim = "grand_ext_email_verified"; + private const string FirstNameClaim = "grand_ext_given_name"; + private const string LastNameClaim = "grand_ext_family_name"; + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOidcCustomerResolver _oidcCustomerResolver; + private readonly ILogger _logger; + + public ExternalRegistrationContext(IHttpContextAccessor httpContextAccessor, IOidcCustomerResolver oidcCustomerResolver, + ILogger logger) + { + _httpContextAccessor = httpContextAccessor; + _oidcCustomerResolver = oidcCustomerResolver; + _logger = logger; + } + + public static ClaimsPrincipal CreatePrincipal(ExternalRegistrationIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + var claims = new List { + new(ProviderClaim, identity.ProviderSystemName), + new(SubjectClaim, identity.Subject), + new(EmailClaim, identity.Email ?? ""), + new(EmailVerifiedClaim, identity.EmailVerified ? "true" : "false") + }; + if (!string.IsNullOrEmpty(identity.FirstName)) claims.Add(new Claim(FirstNameClaim, identity.FirstName)); + if (!string.IsNullOrEmpty(identity.LastName)) claims.Add(new Claim(LastNameClaim, identity.LastName)); + return new ClaimsPrincipal(new ClaimsIdentity(claims, AuthenticationType)); + } + + public async Task GetPending() + { + var result = await HttpContext.AuthenticateAsync(GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme); + var principal = result.Succeeded ? result.Principal : null; + if (principal?.Identity?.AuthenticationType != AuthenticationType) + return null; + + var provider = principal.FindFirst(ProviderClaim)?.Value; + var subject = principal.FindFirst(SubjectClaim)?.Value; + var email = principal.FindFirst(EmailClaim)?.Value; + if (string.IsNullOrEmpty(provider) || string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(email)) + return null; + + return new ExternalRegistrationIdentity(provider, subject, email, + principal.FindFirst(EmailVerifiedClaim)?.Value == "true", + principal.FindFirst(FirstNameClaim)?.Value, principal.FindFirst(LastNameClaim)?.Value); + } + + public Task Clear() => HttpContext.SignOutAsync(GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme); + + public async Task Complete(Customer customer, string storeId) + { + ArgumentNullException.ThrowIfNull(customer); + var pending = await GetPending() ?? throw new InvalidOperationException("No pending external identity"); + try + { + if (pending.ProviderSystemName != OidcResourceServerDefaults.ProviderSystemName) + throw new InvalidOperationException($"Unsupported external provider {pending.ProviderSystemName}"); + + var result = await _oidcCustomerResolver.LinkIdentity(customer, pending.Subject, pending.Email, storeId); + if (result != OidcLinkResult.Linked) + _logger.LogWarning("Pending identity {Subject} not connected to customer {CustomerId}: {Result}", + pending.Subject, customer.Id, result); + return result; + } + finally + { + await Clear(); + } + } + + private HttpContext HttpContext => + _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP request"); +} diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationIdentity.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationIdentity.cs new file mode 100644 index 0000000000..9c56380ca7 --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationIdentity.cs @@ -0,0 +1,13 @@ +namespace Grand.Web.Common.Security.ExternalRegistration; + +/// +/// Identity confirmed by an external provider that is not yet connected to a customer: kept in the external +/// authentication cookie while the user registers or connects an existing account +/// +public record ExternalRegistrationIdentity( + string ProviderSystemName, + string Subject, + string Email, + bool EmailVerified, + string FirstName, + string LastName); diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs new file mode 100644 index 0000000000..ef969ad0cf --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/ExternalRegistrationPolicy.cs @@ -0,0 +1,25 @@ +using Grand.Domain.Customers; + +namespace Grand.Web.Common.Security.ExternalRegistration; + +public static class ExternalRegistrationPolicy +{ + /// + /// Registration type for an account created from a provider identity: a verified e-mail needs no second + /// validation, an unverified one always does (or an administrator's approval, which the store asked for) + /// + public static UserRegistrationType EffectiveRegistrationType(UserRegistrationType storeType, bool emailVerified) => + storeType switch { + UserRegistrationType.EmailValidation when emailVerified => UserRegistrationType.Standard, + UserRegistrationType.Standard when !emailVerified => UserRegistrationType.EmailValidation, + _ => storeType + }; + + /// + /// Whether registering connects the provider identity to the new account and needs no password: only for a + /// verified e-mail. An unverified one registers with a password like any other account and connects later with + /// that password - otherwise whoever holds the identity would keep the account once the owner of the address + /// activates it, even after a password reset. + /// + public static bool ConnectsAtRegistration(ExternalRegistrationIdentity pending) => pending is { EmailVerified: true }; +} diff --git a/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs b/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs new file mode 100644 index 0000000000..b0325fca4c --- /dev/null +++ b/src/Web/Grand.Web.Common/Security/ExternalRegistration/IExternalRegistrationContext.cs @@ -0,0 +1,17 @@ +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; + +namespace Grand.Web.Common.Security.ExternalRegistration; + +public interface IExternalRegistrationContext +{ + /// The pending identity of this browser, or null + Task GetPending(); + + Task Clear(); + + /// Connects the pending identity to the customer and clears it (also when connecting fails) + /// Customer to connect + /// Current store + Task Complete(Customer customer, string storeId); +} diff --git a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs index b1bafda3f9..52c787b622 100644 --- a/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs +++ b/src/Web/Grand.Web.Common/Startup/OidcResourceServerStartup.cs @@ -1,6 +1,7 @@ using Grand.Data; using Grand.Infrastructure; using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.ExternalRegistration; using Grand.Web.Common.Security.Oidc; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -22,6 +23,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 878cea7858..b820881cb8 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index 2ca6ecae76..744f2dde2a 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -3,4 +3,16 @@ API users signed with a shared secret key are deprecated. Configure the OIDC resource server (OidcResourceServer in appsettings) and use access tokens issued by your identity provider. + + You are signing up with your {0} account. No password is needed: you sign in with that account. + + + Cancel + + + Your {0} account did not confirm this e-mail address. Choose a password; after you activate the account, sign in with {0} again and connect it with this password. + + + The account was created, but signing in with your external account could not be connected to it. Sign in with your e-mail (use "Forgot password" to set a password). + diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 09036bd4d1..b3481d1065 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -229,7 +229,7 @@ //api for admin //when enabled (Development environment only), API documentation (Scalar) is available at: https://your-domain/scalar/v1 //locally: https://localhost:44350/scalar/v1 - //DEPRECATED: API users with tokens signed by SecretKey. Prefer OidcResourceServer; this keeps working until it is + //DEPRECATED: API users with tokens signed by SecretKey. Prefer OidcResourceServer (docs/oidc-resource-server.md); this keeps working until it is //removed in a future major version. "BackendAPI": { "Enabled": true, @@ -243,7 +243,7 @@ "ExpiryInMinutes": 1440 }, //OAuth 2.1 resource server: accept access tokens issued by an external OpenID Connect provider on protected - //resources (APIs, integrations, plugin endpoints) + //resources (APIs, integrations, plugin endpoints). See docs/oidc-resource-server.md. "OidcResourceServer": { "Enabled": false, //issuer of the identity provider; discovery document at {Authority}/.well-known/openid-configuration diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 3317f5fcca..3d8540af6c 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -5,6 +5,7 @@ using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Interfaces.Messages; using Grand.Business.Core.Queries.Customers; +using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Utilities.Customers; using Grand.Domain.Common; using Grand.Domain.Customers; @@ -18,6 +19,7 @@ using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authorization; using Grand.Web.Common.Security.Captcha; +using Grand.Web.Common.Security.ExternalRegistration; using Grand.Web.Extensions; using Grand.Web.Features.Models.Common; using Grand.Web.Features.Models.Customers; @@ -25,6 +27,7 @@ using Grand.Web.Models.Customer; using Grand.Mediator; using Microsoft.AspNetCore.Mvc; +using System.Security.Cryptography; namespace Grand.Web.Controllers; @@ -217,6 +220,11 @@ await _customerManagerService.LoginCustomer( { var userName = _customerSettings.UsernamesEnabled ? model.Username : model.Email; HttpContext.Session.SetString("RequiresTwoFactor", userName); + //the sign-in that follows the code must still go where the login was asked to go + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + HttpContext.Session.SetString("RequiresTwoFactorReturnUrl", returnUrl); + else + HttpContext.Session.Remove("RequiresTwoFactorReturnUrl"); return RedirectToRoute("TwoFactorAuthorization"); } } @@ -289,9 +297,11 @@ public async Task TwoFactorAuthorization(string token, { //remove session HttpContext.Session.Remove("RequiresTwoFactor"); + var returnUrl = HttpContext.Session.GetString("RequiresTwoFactorReturnUrl"); + HttpContext.Session.Remove("RequiresTwoFactorReturnUrl"); //sign in - return await SignInAction(customer); + return await SignInAction(customer, false, returnUrl); } ModelState.AddModelError("", @@ -424,7 +434,9 @@ await _customerManagerService.ChangePassword(new ChangePasswordRequest(model.Ema //available even when navigation is not allowed [PublicStore(true)] [HttpGet] - public virtual async Task> Register() + public virtual async Task> Register( + [FromServices] IExternalRegistrationContext externalRegistrationContext, + [FromServices] IExternalAuthenticationService externalAuthenticationService) { //check whether registration is allowed if (_customerSettings.UserRegistrationType == UserRegistrationType.Disabled) @@ -440,13 +452,32 @@ public virtual async Task> Register() Store = _contextAccessor.StoreContext.CurrentStore }); + PrepareExternalRegistration(model, await externalRegistrationContext.GetPending(), externalAuthenticationService); return View(model); } + private static void PrepareExternalRegistration(RegisterModel model, ExternalRegistrationIdentity pending, + IExternalAuthenticationService externalAuthenticationService) + { + model.ExternalRegistration = pending != null; + model.ExternalConnects = ExternalRegistrationPolicy.ConnectsAtRegistration(pending); + if (pending == null) + return; + + model.Email = pending.Email; + if (string.IsNullOrEmpty(model.FirstName)) model.FirstName = pending.FirstName; + if (string.IsNullOrEmpty(model.LastName)) model.LastName = pending.LastName; + model.ExternalProviderName = + externalAuthenticationService.LoadAuthenticationProviderBySystemName(pending.ProviderSystemName)?.FriendlyName + ?? pending.ProviderSystemName; + } + [HttpPost] //available even when navigation is not allowed [PublicStore(true)] - public virtual async Task Register(RegisterModel model, string returnUrl) + public virtual async Task Register(RegisterModel model, string returnUrl, + [FromServices] IExternalRegistrationContext externalRegistrationContext, + [FromServices] IExternalAuthenticationService externalAuthenticationService) { //check whether registration is allowed if (_customerSettings.UserRegistrationType == UserRegistrationType.Disabled) @@ -455,16 +486,39 @@ public virtual async Task Register(RegisterModel model, string re //check if customer is registered. if (await _groupService.IsRegistered(_contextAccessor.WorkContext.CurrentCustomer)) return RedirectToRoute("HomePage"); + var pending = await externalRegistrationContext.GetPending(); + if (pending != null) + model.Email = pending.Email; + var registrationType = pending == null + ? _customerSettings.UserRegistrationType + : ExternalRegistrationPolicy.EffectiveRegistrationType(_customerSettings.UserRegistrationType, pending.EmailVerified); + var connects = ExternalRegistrationPolicy.ConnectsAtRegistration(pending); + if (ModelState.IsValid) { if (_customerSettings.UsernamesEnabled && model.Username != null) model.Username = model.Username.Trim(); - var isApproved = _customerSettings.UserRegistrationType == UserRegistrationType.Standard; + var isApproved = registrationType == UserRegistrationType.Standard; var registrationRequest = new RegistrationRequest(_contextAccessor.WorkContext.CurrentCustomer, model.Email, - _customerSettings.UsernamesEnabled ? model.Username : model.Email, model.Password, + _customerSettings.UsernamesEnabled ? model.Username : model.Email, + connects ? Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) : model.Password, _customerSettings.DefaultPasswordFormat, _contextAccessor.StoreContext.CurrentStore.Id, isApproved); await _customerManagerService.RegisterCustomer(registrationRequest); + //connect the provider identity before anything can sign the customer in + if (connects) + { + var linkResult = await externalRegistrationContext.Complete(_contextAccessor.WorkContext.CurrentCustomer, + _contextAccessor.StoreContext.CurrentStore.Id); + //the account is still the customer's (verified e-mail), only signing in with the provider is not connected + if (linkResult != OidcLinkResult.Linked) + Warning(_translationService.GetResource("Account.Register.External.NotConnected")); + } + else if (pending != null) + { + await externalRegistrationContext.Clear(); + } + var customerAttributes = await _mediator.Send(new GetParseCustomAttributes { SelectedAttributes = model.SelectedAttributes }); @@ -482,7 +536,7 @@ await _mediator.Send(new CustomerRegisteredCommand { //raise event await _mediator.Publish(new CustomerRegisteredEvent(_contextAccessor.WorkContext.CurrentCustomer)); - switch (_customerSettings.UserRegistrationType) + switch (registrationType) { case UserRegistrationType.EmailValidation: { @@ -533,9 +587,20 @@ await _messageProviderService.SendCustomerWelcomeMessage(_contextAccessor.WorkCo { SelectedAttributes = model.SelectedAttributes }) }); + PrepareExternalRegistration(model, pending, externalAuthenticationService); return View(model); } + //available even when navigation is not allowed + [PublicStore(true)] + [HttpGet] + public virtual async Task RegisterExternalCancel( + [FromServices] IExternalRegistrationContext externalRegistrationContext) + { + await externalRegistrationContext.Clear(); + return RedirectToRoute("Register"); + } + //available even when navigation is not allowed [PublicStore(true)] [IgnoreApi] diff --git a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs index 2e4720ab51..36524a3b05 100644 --- a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs +++ b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs @@ -73,6 +73,9 @@ private void RegisterAccountRoute(IEndpointRouteBuilder endpointRouteBuilder, st endpointRouteBuilder.MapControllerRoute("Register", $"{pattern}register/", new { controller = "Account", action = "Register" }); + endpointRouteBuilder.MapControllerRoute("RegisterExternalCancel", + $"{pattern}register/external/cancel/", + new { controller = "Account", action = "RegisterExternalCancel" }); //logout endpointRouteBuilder.MapControllerRoute("Logout", $"{pattern}logout/", diff --git a/src/Web/Grand.Web/Models/Customer/RegisterModel.cs b/src/Web/Grand.Web/Models/Customer/RegisterModel.cs index b32a9b4603..5b082b170d 100644 --- a/src/Web/Grand.Web/Models/Customer/RegisterModel.cs +++ b/src/Web/Grand.Web/Models/Customer/RegisterModel.cs @@ -25,6 +25,14 @@ public class RegisterModel : BaseModel public bool CheckUsernameAvailabilityEnabled { get; set; } + /// Display only: the server decides external mode from the pending identity, never from this value + public bool ExternalRegistration { get; set; } + + public string ExternalProviderName { get; set; } + + /// Display only: the provider identity is connected on registration and no password is asked for + public bool ExternalConnects { get; set; } + [DataType(DataType.Password)] [GrandResourceDisplayName("Account.Fields.Password")] public string Password { get; set; } diff --git a/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs b/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs index b3fa34b120..1330605a2a 100644 --- a/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs @@ -10,6 +10,7 @@ using Grand.Infrastructure.Validators; using Grand.SharedKernel.Captcha; using Grand.SharedKernel.Extensions; +using Grand.Web.Common.Security.ExternalRegistration; using Grand.Web.Common.Validators; using Grand.Web.Features.Models.Customers; using Grand.Web.Models.Customer; @@ -29,7 +30,8 @@ public RegisterValidator( IMediator mediator, ICustomerAttributeParser customerAttributeParser, ICustomerService customerService, IGroupService groupService, IContextAccessor contextAccessor, - CustomerConfig customerConfig + CustomerConfig customerConfig, + IExternalRegistrationContext externalRegistrationContext ) : base(validators) { @@ -52,17 +54,21 @@ CustomerConfig customerConfig .WithMessage(translationService.GetResource("Account.Fields.LastName.Required")); } - RuleFor(x => x.Password).NotEmpty() - .WithMessage(translationService.GetResource("Account.Fields.Password.Required")); + //an account connected to a provider identity (verified e-mail) has no password the customer types + WhenAsync(async (_, _) => !ExternalRegistrationPolicy.ConnectsAtRegistration(await externalRegistrationContext.GetPending()), () => + { + RuleFor(x => x.Password).NotEmpty() + .WithMessage(translationService.GetResource("Account.Fields.Password.Required")); - if (!string.IsNullOrEmpty(customerSettings.PasswordRegularExpression)) - RuleFor(x => x.Password).Matches(customerSettings.PasswordRegularExpression) - .WithMessage(string.Format(translationService.GetResource("Account.Fields.Password.Validation"))); + if (!string.IsNullOrEmpty(customerSettings.PasswordRegularExpression)) + RuleFor(x => x.Password).Matches(customerSettings.PasswordRegularExpression) + .WithMessage(string.Format(translationService.GetResource("Account.Fields.Password.Validation"))); - RuleFor(x => x.ConfirmPassword).NotEmpty() - .WithMessage(translationService.GetResource("Account.Fields.ConfirmPassword.Required")); - RuleFor(x => x.ConfirmPassword).Equal(x => x.Password) - .WithMessage(translationService.GetResource("Account.Fields.Password.EnteredPasswordsDoNotMatch")); + RuleFor(x => x.ConfirmPassword).NotEmpty() + .WithMessage(translationService.GetResource("Account.Fields.ConfirmPassword.Required")); + RuleFor(x => x.ConfirmPassword).Equal(x => x.Password) + .WithMessage(translationService.GetResource("Account.Fields.Password.EnteredPasswordsDoNotMatch")); + }); //form fields if (customerSettings.CountryEnabled && customerSettings.CountryRequired) @@ -149,7 +155,9 @@ CustomerConfig customerConfig //validate unique user - if (await customerService.GetCustomerByEmail(x.Email, storeId) != null) + //with a pending provider identity the account gets the provider's e-mail, whatever the form says + var email = (await externalRegistrationContext.GetPending())?.Email ?? x.Email; + if (await customerService.GetCustomerByEmail(email, storeId) != null) { context.AddFailure(translationService.GetResource("Account.Register.Errors.EmailAlreadyExists")); return; diff --git a/src/Web/Grand.Web/Views/Account/Info.cshtml b/src/Web/Grand.Web/Views/Account/Info.cshtml index eb099dba0b..6956c38921 100644 --- a/src/Web/Grand.Web/Views/Account/Info.cshtml +++ b/src/Web/Grand.Web/Views/Account/Info.cshtml @@ -506,7 +506,7 @@ @CommonHelper.EnsureMaximumLength(item.ExternalIdentifier, 40, "...") - @Loc["Account.AssociatedExternalAuth.Remove"] + } @@ -610,3 +610,16 @@ }, res = new { warning = Loc["common.warning"].ToString() } }) +@if (Model.AssociatedExternalAuthRecords.Count > 0) +{ + @*Behaviour lives in vueapp/src/behaviours/confirm-delete.js, shared with the address and sub-account lists.*@ + +} diff --git a/src/Web/Grand.Web/Views/Account/Register.cshtml b/src/Web/Grand.Web/Views/Account/Register.cshtml index b864b92be6..fb3726afb0 100644 --- a/src/Web/Grand.Web/Views/Account/Register.cshtml +++ b/src/Web/Grand.Web/Views/Account/Register.cshtml @@ -16,6 +16,13 @@
+@if (Model.ExternalRegistration) +{ +
+ @string.Format(Loc[Model.ExternalConnects ? "Account.Register.External.Notice" : "Account.Register.External.NoticeUnverified"], Model.ExternalProviderName) + @Loc["Account.Register.External.Cancel"] +
+}

@Loc["Account.YourPersonalDetails"]

@@ -112,7 +119,7 @@ * - + {{ errors[0] }} @@ -413,27 +420,33 @@
} -
-
-

@Loc["Account.YourPassword"]

-
-
-
- - - - {{ errors[0] }} - - -
-
- - - - {{ errors[0] }} - - +