@nauticana/sail 1.1.32 → 1.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -8
- package/package.json +1 -1
- package/src/component/login/login_component.html +11 -0
- package/src/component/login/login_component.ts +2 -1
- package/src/component/security/sessions.html +46 -0
- package/src/component/security/sessions.ts +45 -0
- package/src/component/security/twofactor_verify.html +12 -8
- package/src/component/security/twofactor_verify.ts +2 -1
- package/src/index.ts +1 -0
- package/src/model/appdata.ts +12 -0
- package/src/service/auth.service.ts +83 -44
- package/src/service/rest_url.ts +2 -0
- package/src/util/errors.ts +7 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
|
|
4
4
|
|
|
5
|
-
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
5
|
+
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.33 adopts keel v1.2.106 (`prompt` on the login page, new-device step-up, sign-in sessions), sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
6
6
|
|
|
7
7
|
**Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
|
|
8
8
|
|
|
@@ -16,7 +16,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
16
16
|
| **Form components** | `DynamicField`, `RecordForm`, `TableForm` |
|
|
17
17
|
| **Navigation** | `Navigation` (sidenav + toolbar with menu, responsive) |
|
|
18
18
|
| **Login** | `LoginComponent`, `RegisterComponent`, `ChpassComponent`, `ConfirmRegisterComponent`, `ConfirmChpassComponent` |
|
|
19
|
-
| **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `AccountDeletionComponent` |
|
|
19
|
+
| **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `SessionsComponent`, `AccountDeletionComponent` |
|
|
20
20
|
| **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
|
|
21
21
|
| **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent`, `SsoLoginComponent`, `SsoReturnComponent` |
|
|
22
22
|
| **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
|
|
@@ -221,7 +221,7 @@ Key CSS classes used by components:
|
|
|
221
221
|
|
|
222
222
|
/* Security */
|
|
223
223
|
.twofactor-qr, .twofactor-backup, .backup-code-list,
|
|
224
|
-
.trusted-devices-table
|
|
224
|
+
.trusted-devices-table, .sessions-table
|
|
225
225
|
```
|
|
226
226
|
|
|
227
227
|
## How it works
|
|
@@ -331,6 +331,11 @@ origin, is not followed. Failures set `BaseAuthService.sessionHandoffError` (ren
|
|
|
331
331
|
`<sail-login>`) and never redirect: 400 means keel rejected the return URL, 401 means the user
|
|
332
332
|
signs in again to retry. The authorization server must allow the SPA origin by CORS.
|
|
333
333
|
|
|
334
|
+
With `prompt=login` or `prompt=select_account`, `<sail-login>` holds back the stored session and
|
|
335
|
+
offers sign-out (`isLoggedIn` stays false); account selection also offers `continueSession()`. A
|
|
336
|
+
fresh sign-in, including its 2FA step, keeps the hand-off query. Any sign-in revokes the refresh
|
|
337
|
+
token it replaces.
|
|
338
|
+
|
|
334
339
|
Set `passwordPolicyUrl` to the app's public route for keel's `PublicHandler.GetPasswordPolicy`
|
|
335
340
|
(e.g. `'/public/v1/auth/password/policy'`). `BaseAuthService.ensurePasswordPolicy()` fetches it
|
|
336
341
|
once into the `passwordRules` signal, and `passwordPolicyValidator(() => auth.passwordRules())`
|
|
@@ -348,7 +353,7 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
|
|
|
348
353
|
| `POST /public/otp/send` | Send OTP code to phone or email; returns opaque `otpToken` |
|
|
349
354
|
| `POST /public/otp/verify` | Verify OTP code with `otpToken`, returns JWT |
|
|
350
355
|
| `POST /public/otp/resend` | Re-issue OTP for an existing `otpToken` |
|
|
351
|
-
| `POST /public/2fa/verify` | Login-time TOTP verification (uses `loginToken`) |
|
|
356
|
+
| `POST /public/2fa/verify` | Login-time TOTP or emailed step-up code verification (uses `loginToken`) |
|
|
352
357
|
| `POST /public/2fa/backup-verify` | Login-time backup-code verification |
|
|
353
358
|
| `POST /public/register` | Start a signup from a `PartnerRegistration`; emails the confirmation code |
|
|
354
359
|
| `POST /public/register/confirm` | Confirm `?email=&code=`; answers session tokens and the partner result (`confirmRegister`) |
|
|
@@ -367,6 +372,8 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
|
|
|
367
372
|
| `POST /api/user/2fa/disable` | Disable 2FA — **requires password + current TOTP code** |
|
|
368
373
|
| `GET /api/user/trusted-device/list` | List trusted devices |
|
|
369
374
|
| `POST /api/user/trusted-device/revoke` | Revoke a trusted device |
|
|
375
|
+
| `GET /api/user/sessions` | The caller's live sign-in sessions (`getSessions`) |
|
|
376
|
+
| `POST /api/user/sessions/revoke` | Sign out one session `{id}` (`revokeSession`); 404 `session_not_found` |
|
|
370
377
|
| `POST /api/user/logout-everywhere` | Sign out of all devices — **requires re-auth** |
|
|
371
378
|
| `DELETE /api/user/account` | Soft-delete the caller's account — **requires re-auth** |
|
|
372
379
|
| `GET /api/notifications` | Notification inbox page — `?before=<id>&limit=<n>`, newest first (keel v1.2.71) |
|
|
@@ -382,13 +389,13 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
|
|
|
382
389
|
| `GET /api/billing/invoices` | Invoice history |
|
|
383
390
|
| `GET /api/billing/payment-methods` | Saved payment methods |
|
|
384
391
|
|
|
385
|
-
Device registration happens within `/public/2fa/verify` when `trustDevice=true`. Since **keel v0.9 / sail v0.9.0**, the trusted-device credential is a server-minted `keel_td` cookie (HttpOnly + Secure + SameSite=Strict) — not a client-supplied fingerprint. sail sets `withCredentials: true` on
|
|
392
|
+
Device registration happens within `/public/2fa/verify` when `trustDevice=true`. Since **keel v0.9 / sail v0.9.0**, the trusted-device credential is a server-minted `keel_td` cookie (HttpOnly + Secure + SameSite=Strict) — not a client-supplied fingerprint. sail sets `withCredentials: true` on every session-minting call (password, Google, social, OTP, 2FA and backup verify, registration confirm and exchange) so this cookie and keel's `keel_device` cookie (keel v1.2.106: a recognized device gets no new-device notice or step-up) round-trip; see [Enabling 2FA → Cross-origin cookie requirement](#cross-origin-cookie-requirement) below.
|
|
386
393
|
|
|
387
394
|
## Enabling 2FA in your project
|
|
388
395
|
|
|
389
396
|
Add the 2FA verification route to your `publicRoutes` config:
|
|
390
397
|
```typescript
|
|
391
|
-
import {
|
|
398
|
+
import { SessionsComponent, TrustedDevicesComponent, TwoFactorSetupComponent, TwoFactorVerifyComponent } from '@nauticana/sail';
|
|
392
399
|
|
|
393
400
|
// In publicRoutes:
|
|
394
401
|
{ path: 'login/2fa', component: TwoFactorVerifyComponent }
|
|
@@ -396,9 +403,12 @@ import { TwoFactorVerifyComponent, TwoFactorSetupComponent, TrustedDevicesCompon
|
|
|
396
403
|
// In extraRoutes or authenticated routes (optional — for user self-service):
|
|
397
404
|
{ path: 'security/2fa', component: TwoFactorSetupComponent }
|
|
398
405
|
{ path: 'security/devices', component: TrustedDevicesComponent }
|
|
406
|
+
{ path: 'security/sessions', component: SessionsComponent }
|
|
399
407
|
```
|
|
400
408
|
|
|
401
|
-
The login flow
|
|
409
|
+
The login flow redirects `twoFactorRequired` responses to `/login/2fa`. With `twoFactorMethod: 'email'`, the page explains the new-device check and hides device trust and backup codes. Sign-in refusals `signin_network` and `stepup_unavailable` have user-facing messages.
|
|
410
|
+
|
|
411
|
+
`SessionsComponent` (`<sail-sessions>`) lists the sessions with their `sign_in_method` captions, marks the current one, and signs out any other.
|
|
402
412
|
|
|
403
413
|
### Cross-origin cookie requirement
|
|
404
414
|
|
|
@@ -407,7 +417,7 @@ The "trust this device" feature (keel v0.9+) is backed by a server-set HttpOnly
|
|
|
407
417
|
1. On `POST /public/2fa/verify` with `trustDevice: true`, keel mints a 32-byte secret, stores its SHA-256, and returns the raw secret to the browser **only** as a `Set-Cookie: keel_td=…` header (never in the JSON body).
|
|
408
418
|
2. On the next `POST /public/login/local` (or `/public/login/gmail`), the browser sends the `keel_td` cookie back, keel hashes it, finds the matching row, and **skips the 2FA prompt**.
|
|
409
419
|
|
|
410
|
-
For the browser to store and resend that cookie, the requests must be credentialed. sail already sets `withCredentials: true` on
|
|
420
|
+
For the browser to store and resend that cookie, the requests must be credentialed. sail already sets `withCredentials: true` on these calls — **you do not add this in your app code**. But the keel backend must permit credentialed CORS, or the browser drops the cookie and every login re-prompts for 2FA:
|
|
411
421
|
|
|
412
422
|
- **Same-origin** deployments (SPA served from the same host as the API) work with no extra config — credentialed same-origin requests are always allowed.
|
|
413
423
|
- **Cross-origin** deployments (SPA on a different host/port than the API) require the keel `HttpBackend` to set:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.33",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -15,6 +15,17 @@
|
|
|
15
15
|
@if (sessionHandoffError()) {
|
|
16
16
|
<p class="auth-error" role="alert">{{ sessionHandoffError() }}</p>
|
|
17
17
|
}
|
|
18
|
+
@if (signInPrompt(); as prompt) {
|
|
19
|
+
<p class="auth-message" role="status">
|
|
20
|
+
{{ prompt === 'login' ? 'Sign in again to continue.' : 'Choose the account to continue with.' }}
|
|
21
|
+
</p>
|
|
22
|
+
<div class="auth-actions">
|
|
23
|
+
@if (prompt === 'select_account') {
|
|
24
|
+
<button matButton="elevated" class="primary" type="button" (click)="auth.continueSession()">Continue with the signed-in account</button>
|
|
25
|
+
}
|
|
26
|
+
<button matButton type="button" (click)="auth.signOutHere()">Sign out of the current account</button>
|
|
27
|
+
</div>
|
|
28
|
+
}
|
|
18
29
|
<form [formGroup]="loginForm" (ngSubmit)="login()" class="auth-form">
|
|
19
30
|
<mat-form-field appearance="outline">
|
|
20
31
|
<mat-label>Username</mat-label>
|
|
@@ -27,7 +27,7 @@ import { SsoLoginComponent } from '../sso/sso_login';
|
|
|
27
27
|
],
|
|
28
28
|
})
|
|
29
29
|
export class LoginComponent extends BaseAsync {
|
|
30
|
-
|
|
30
|
+
protected readonly auth = inject(BaseAuthService);
|
|
31
31
|
protected fb = inject(FormBuilder);
|
|
32
32
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
33
33
|
private readonly queryParams = inject(ActivatedRoute).snapshot.queryParamMap;
|
|
@@ -35,6 +35,7 @@ export class LoginComponent extends BaseAsync {
|
|
|
35
35
|
protected readonly passwordChanged = this.queryParams.get('passwordChanged') === 'true';
|
|
36
36
|
/** OAuth session hand-off failure from BaseAuthService, shown below. */
|
|
37
37
|
protected readonly sessionHandoffError = this.auth.sessionHandoffError;
|
|
38
|
+
protected readonly signInPrompt = this.auth.signInPrompt;
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Auto-show the embedded SocialLoginComponent whenever at least one
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<div class="auth-container">
|
|
2
|
+
<div class="auth-card">
|
|
3
|
+
<h2 class="auth-title">Sessions</h2>
|
|
4
|
+
<p class="auth-instructions">Where your account is signed in.</p>
|
|
5
|
+
|
|
6
|
+
@if (sessions().length > 0) {
|
|
7
|
+
<table class="sessions-table">
|
|
8
|
+
<thead>
|
|
9
|
+
<tr>
|
|
10
|
+
<th>Device</th>
|
|
11
|
+
<th>Address</th>
|
|
12
|
+
<th>Signed In</th>
|
|
13
|
+
<th>Last Active</th>
|
|
14
|
+
<th><span class="cdk-visually-hidden">Actions</span></th>
|
|
15
|
+
</tr>
|
|
16
|
+
</thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
@for (session of sessions(); track session.id) {
|
|
19
|
+
<tr>
|
|
20
|
+
<td>{{ session.userAgent || 'Unknown device' }} ({{ methodCaption(session.signInMethod) }})</td>
|
|
21
|
+
<td>{{ session.clientIp }}</td>
|
|
22
|
+
<td>{{ session.createdAt | date: 'short' }}</td>
|
|
23
|
+
<td>{{ session.lastSeenAt | date: 'short' }}</td>
|
|
24
|
+
<td>
|
|
25
|
+
@if (session.current) {
|
|
26
|
+
This device
|
|
27
|
+
} @else {
|
|
28
|
+
<button matButton type="button" (click)="revoke(session)" [disabled]="loading()">Sign out</button>
|
|
29
|
+
}
|
|
30
|
+
</td>
|
|
31
|
+
</tr>
|
|
32
|
+
}
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
35
|
+
} @else if (!loading()) {
|
|
36
|
+
<p class="empty-state">No active sessions.</p>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@if (errorMessage()) {
|
|
40
|
+
<div class="auth-error" role="alert">{{ errorMessage() }}</div>
|
|
41
|
+
}
|
|
42
|
+
@if (successMessage()) {
|
|
43
|
+
<div class="auth-success" role="status">{{ successMessage() }}</div>
|
|
44
|
+
}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, signal } from '@angular/core';
|
|
2
|
+
import { DatePipe } from '@angular/common';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
5
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
6
|
+
import { ActiveSession } from '../../model/appdata';
|
|
7
|
+
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'sail-sessions',
|
|
10
|
+
templateUrl: './sessions.html',
|
|
11
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
12
|
+
encapsulation: ViewEncapsulation.None,
|
|
13
|
+
imports: [DatePipe, MatButtonModule],
|
|
14
|
+
})
|
|
15
|
+
export class SessionsComponent extends BaseAsync implements OnInit {
|
|
16
|
+
private readonly auth = inject(BaseAuthService);
|
|
17
|
+
|
|
18
|
+
readonly sessions = signal<ActiveSession[]>([]);
|
|
19
|
+
private readonly methods = new Map(
|
|
20
|
+
(this.auth.getDomainValues('sign_in_method') ?? []).map((v) => [v.Value, v.Caption]));
|
|
21
|
+
|
|
22
|
+
ngOnInit() {
|
|
23
|
+
this.loadSessions();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
loadSessions() {
|
|
27
|
+
this.run(this.auth.getSessions(), (sessions) => this.sessions.set(sessions), 'Failed to load sessions.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
methodCaption(code: string): string {
|
|
31
|
+
return this.methods.get(code) ?? code;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
revoke(session: ActiveSession) {
|
|
35
|
+
if (!confirm(`Sign out the session on ${session.userAgent || 'an unknown device'}?`)) return;
|
|
36
|
+
this.run(
|
|
37
|
+
this.auth.revokeSession(session.id),
|
|
38
|
+
() => {
|
|
39
|
+
this.loadSessions();
|
|
40
|
+
this.successMessage.set('Session signed out.');
|
|
41
|
+
},
|
|
42
|
+
'Failed to sign out the session.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
</div>
|
|
6
6
|
}
|
|
7
7
|
<div class="auth-card">
|
|
8
|
-
<h2 class="auth-title">Two-Factor Verification</h2>
|
|
8
|
+
<h2 class="auth-title">{{ emailed ? 'Confirm Sign-In' : 'Two-Factor Verification' }}</h2>
|
|
9
9
|
<p class="auth-instructions">
|
|
10
|
-
@if (
|
|
10
|
+
@if (emailed) {
|
|
11
|
+
We emailed you a sign-in code because this device is new. Enter it to continue.
|
|
12
|
+
} @else if (useBackupCode()) {
|
|
11
13
|
Enter one of your backup codes.
|
|
12
14
|
} @else {
|
|
13
15
|
Enter the 6-digit code from your authenticator app.
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
<input matInput type="text" formControlName="code" autocomplete="one-time-code" required>
|
|
20
22
|
</mat-form-field>
|
|
21
23
|
|
|
22
|
-
@if (!useBackupCode()) {
|
|
24
|
+
@if (!useBackupCode() && !emailed) {
|
|
23
25
|
<div class="auth-checkbox">
|
|
24
26
|
<mat-checkbox formControlName="trustDevice">
|
|
25
27
|
Trust this device for 30 days
|
|
@@ -36,11 +38,13 @@
|
|
|
36
38
|
</button>
|
|
37
39
|
</form>
|
|
38
40
|
<div class="auth-actions">
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
@if (!emailed) {
|
|
42
|
+
<a tabindex="0" role="button" (click)="toggleBackupCode()" (keydown.enter)="toggleBackupCode()"
|
|
43
|
+
(keydown.space)="$event.preventDefault(); toggleBackupCode()">
|
|
44
|
+
{{ useBackupCode() ? 'Use authenticator code' : 'Use a backup code instead' }}
|
|
45
|
+
</a>
|
|
46
|
+
}
|
|
47
|
+
<a routerLink="/login/local" queryParamsHandling="preserve">Back to Login</a>
|
|
44
48
|
</div>
|
|
45
49
|
</div>
|
|
46
50
|
</div>
|
|
@@ -29,6 +29,7 @@ export class TwoFactorVerifyComponent extends BaseAsync {
|
|
|
29
29
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
30
30
|
|
|
31
31
|
readonly useBackupCode = signal(false);
|
|
32
|
+
readonly emailed = this.auth.pendingTwoFactorMethod() === 'email';
|
|
32
33
|
|
|
33
34
|
readonly verifyForm = this.fb.group({
|
|
34
35
|
code: ['', [Validators.required, Validators.minLength(6)]],
|
|
@@ -58,7 +59,7 @@ export class TwoFactorVerifyComponent extends BaseAsync {
|
|
|
58
59
|
);
|
|
59
60
|
} else {
|
|
60
61
|
this.run(
|
|
61
|
-
this.auth.verify2FALogin(code!,
|
|
62
|
+
this.auth.verify2FALogin(code!, !this.emailed && !!trustDevice, this.getDeviceName()),
|
|
62
63
|
(res) => {
|
|
63
64
|
if (!res.valid || !res.token) {
|
|
64
65
|
this.errorMessage.set('Invalid authentication code.');
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,7 @@ export * from './component/account/my_account';
|
|
|
52
52
|
export * from './component/security/twofactor_setup';
|
|
53
53
|
export * from './component/security/twofactor_verify';
|
|
54
54
|
export * from './component/security/trusted_devices';
|
|
55
|
+
export * from './component/security/sessions';
|
|
55
56
|
export * from './component/billing/plan_selector';
|
|
56
57
|
export * from './component/billing/price_selector';
|
|
57
58
|
export * from './component/billing/checkout_button';
|
package/src/model/appdata.ts
CHANGED
|
@@ -164,6 +164,8 @@ export interface LoginResponse2FA {
|
|
|
164
164
|
refreshToken?: string;
|
|
165
165
|
twoFactorRequired: boolean;
|
|
166
166
|
loginToken?: string;
|
|
167
|
+
/** 'email': a new-device step-up code was emailed; absent: the authenticator app. */
|
|
168
|
+
twoFactorMethod?: 'email';
|
|
167
169
|
userId?: number;
|
|
168
170
|
partnerId?: number;
|
|
169
171
|
// Populated for users with the single-device session policy enabled.
|
|
@@ -198,6 +200,16 @@ export interface TrustedDevice {
|
|
|
198
200
|
created_at: string;
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
export interface ActiveSession {
|
|
204
|
+
id: number;
|
|
205
|
+
userAgent: string;
|
|
206
|
+
clientIp: string;
|
|
207
|
+
signInMethod: string;
|
|
208
|
+
createdAt: string;
|
|
209
|
+
lastSeenAt: string;
|
|
210
|
+
current: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
201
213
|
// Mirrors keel `user.AccountRegistration`.
|
|
202
214
|
export interface AccountRegistration {
|
|
203
215
|
firstName: string;
|
|
@@ -2,7 +2,7 @@ import { Injectable, WritableSignal, inject, signal } from '@angular/core';
|
|
|
2
2
|
import { HttpErrorResponse, HttpParams } from '@angular/common/http';
|
|
3
3
|
import { filter, finalize, map, mergeMap, shareReplay, take, tap } from 'rxjs/operators';
|
|
4
4
|
import { PasswordRules } from '../util/password_policy';
|
|
5
|
-
import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerCreated, PartnerRegistration, PartnerSetup, RestReport, TableDefinition, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
|
|
5
|
+
import { ActiveSession, ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerCreated, PartnerRegistration, PartnerSetup, RestReport, TableDefinition, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
|
|
6
6
|
import {
|
|
7
7
|
LoginResponseSocial,
|
|
8
8
|
OtpRequest, OtpResendRequest, OtpResponse, OtpVerifyRequest, OtpVerifyResponse,
|
|
@@ -104,6 +104,8 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
104
104
|
protected get trustedDeviceListUrl() { return this.url(RestURL.trustedDeviceListURL); }
|
|
105
105
|
protected get trustedDeviceRegisterUrl() { return this.url(RestURL.trustedDeviceRegisterURL); }
|
|
106
106
|
protected get trustedDeviceRevokeUrl() { return this.url(RestURL.trustedDeviceRevokeURL); }
|
|
107
|
+
protected get sessionListUrl() { return this.url(RestURL.sessionListURL); }
|
|
108
|
+
protected get sessionRevokeUrl() { return this.url(RestURL.sessionRevokeURL); }
|
|
107
109
|
protected get otpSendUrl() { return this.url(RestURL.otpSendURL); }
|
|
108
110
|
protected get otpVerifyUrl() { return this.url(RestURL.otpVerifyURL); }
|
|
109
111
|
protected get otpResendUrl() { return this.url(RestURL.otpResendURL); }
|
|
@@ -146,6 +148,8 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
146
148
|
* signs in again to retry instead of being redirected without a session. */
|
|
147
149
|
readonly sessionHandoffError = signal<string | null>(null);
|
|
148
150
|
|
|
151
|
+
readonly signInPrompt = signal<'login' | 'select_account' | null>(null);
|
|
152
|
+
|
|
149
153
|
/**
|
|
150
154
|
* Multi-role app support. Consumers that distinguish rider/driver/admin
|
|
151
155
|
* (or any equivalent split) call setRole() after login, then
|
|
@@ -219,9 +223,14 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
219
223
|
private completeLogin(token: string, refreshToken?: string) {
|
|
220
224
|
this.refreshInFlight = null; // a rotation started under the previous session must not land
|
|
221
225
|
this.sessionGeneration++;
|
|
226
|
+
const replaced = localStorage.getItem('refreshToken');
|
|
227
|
+
if (replaced && replaced !== refreshToken) this.revokeRefreshToken(replaced).subscribe();
|
|
222
228
|
this.storeTokens(token, refreshToken);
|
|
229
|
+
localStorage.removeItem('loginToken');
|
|
230
|
+
localStorage.removeItem('twoFactorMethod');
|
|
223
231
|
this.isLoggedIn.set(true);
|
|
224
232
|
this.sessionHandoffError.set(null);
|
|
233
|
+
this.signInPrompt.set(null);
|
|
225
234
|
resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
|
|
226
235
|
if (this.startHandoff()) return;
|
|
227
236
|
this.loadAppData();
|
|
@@ -231,13 +240,27 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
231
240
|
/** Hands the session to the OAuth authorization server when the page carries an
|
|
232
241
|
* allowed `?return=` and oauthServerUrl is set; false when there is nothing to hand off. */
|
|
233
242
|
private startHandoff(): boolean {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (!ret || !oauthServer) return false;
|
|
237
|
-
this.handOffSession(oauthServer, ret);
|
|
243
|
+
if (!this.handoffPending()) return false;
|
|
244
|
+
this.handOffSession(this.oauthServerBase(), this.validatedReturnUrl());
|
|
238
245
|
return true;
|
|
239
246
|
}
|
|
240
247
|
|
|
248
|
+
private handoffPending(): boolean {
|
|
249
|
+
return !!this.validatedReturnUrl() && !!this.oauthServerBase();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
continueSession(): void {
|
|
253
|
+
if (this.signInPrompt() !== 'select_account') return;
|
|
254
|
+
this.signInPrompt.set(null);
|
|
255
|
+
this.adoptStoredToken();
|
|
256
|
+
this.startHandoff();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private adoptStoredToken(): void {
|
|
260
|
+
this.token = localStorage.getItem('jwt');
|
|
261
|
+
this.isLoggedIn.set(!!this.token);
|
|
262
|
+
}
|
|
263
|
+
|
|
241
264
|
/** Exchange the bearer session for the authorization server's own cookie session
|
|
242
265
|
* (keel POST /oauth/session/handoff), then leave the SPA for the URL it returns.
|
|
243
266
|
* A 401 rotates the refresh token once and retries. Any failure stays on the
|
|
@@ -297,7 +320,10 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
297
320
|
private handleLoginResponse(res: LoginResponse2FA): void {
|
|
298
321
|
if (res.twoFactorRequired && res.loginToken) {
|
|
299
322
|
localStorage.setItem('loginToken', res.loginToken);
|
|
300
|
-
|
|
323
|
+
if (res.twoFactorMethod) localStorage.setItem('twoFactorMethod', res.twoFactorMethod);
|
|
324
|
+
else localStorage.removeItem('twoFactorMethod');
|
|
325
|
+
// Preserve ?return= so the login still hands off after the second factor.
|
|
326
|
+
this.router.navigate(['/login/2fa'], { queryParamsHandling: 'preserve' });
|
|
301
327
|
} else {
|
|
302
328
|
this.completeLogin(res.token, res.refreshToken);
|
|
303
329
|
}
|
|
@@ -330,7 +356,8 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
330
356
|
/**
|
|
331
357
|
* POST credentials; success completes the login (or routes to 2FA). Callers
|
|
332
358
|
* subscribe and handle the error channel to surface failures in the UI.
|
|
333
|
-
* withCredentials sends keel's `keel_td` trusted-device cookie (skips 2FA)
|
|
359
|
+
* withCredentials sends keel's `keel_td` trusted-device cookie (skips 2FA)
|
|
360
|
+
* and `keel_device` cookie (a recognized device), as every session-minting call does.
|
|
334
361
|
*/
|
|
335
362
|
login(username: string, password: string): Observable<LoginResponse2FA> {
|
|
336
363
|
return this.http.post<LoginResponse2FA>(this.loginUrl, { username, password }, { withCredentials: true }).pipe(
|
|
@@ -362,40 +389,29 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
362
389
|
return this.http.post<TwoFactorVerifyResponse>(this.twoFactorVerifyUrl, request);
|
|
363
390
|
}
|
|
364
391
|
|
|
392
|
+
pendingTwoFactorMethod(): LoginResponse2FA['twoFactorMethod'] {
|
|
393
|
+
return localStorage.getItem('twoFactorMethod') === 'email' ? 'email' : undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
365
396
|
/**
|
|
366
|
-
* Verify
|
|
367
|
-
*
|
|
368
|
-
* valid code the login is completed (token stored, app data + routes loaded).
|
|
397
|
+
* Verify the login-time code (`/public/2fa/verify`): a TOTP code, or the
|
|
398
|
+
* emailed code of a new-device step-up. A valid code completes the login.
|
|
369
399
|
* withCredentials so the browser stores the `keel_td` cookie keel sets on a
|
|
370
400
|
* `trustDevice:true` verify — that cookie is what skips 2FA next login.
|
|
371
401
|
*/
|
|
372
402
|
verify2FALogin(code: string, trustDevice = false, deviceName?: string): Observable<TwoFactorVerifyResponse> {
|
|
373
|
-
|
|
374
|
-
code,
|
|
375
|
-
loginToken: localStorage.getItem('loginToken') ?? '',
|
|
376
|
-
trustDevice,
|
|
377
|
-
deviceName,
|
|
378
|
-
};
|
|
379
|
-
return this.http.post<TwoFactorVerifyResponse>(this.twoFactorLoginVerifyUrl, request, { withCredentials: true }).pipe(
|
|
380
|
-
tap((res) => {
|
|
381
|
-
if (res.valid && res.token) {
|
|
382
|
-
localStorage.removeItem('loginToken');
|
|
383
|
-
this.completeLogin(res.token, res.refreshToken);
|
|
384
|
-
}
|
|
385
|
-
}),
|
|
386
|
-
);
|
|
403
|
+
return this.verifyLogin(this.twoFactorLoginVerifyUrl, { code, trustDevice, deviceName });
|
|
387
404
|
}
|
|
388
405
|
|
|
389
|
-
/** Verify a login-flow backup code;
|
|
406
|
+
/** Verify a login-flow backup code; same contract as verify2FALogin. */
|
|
390
407
|
verifyBackupCode(code: string): Observable<TwoFactorVerifyResponse> {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}),
|
|
408
|
+
return this.verifyLogin(this.twoFactorBackupVerifyUrl, { code });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private verifyLogin(url: string, body: object): Observable<TwoFactorVerifyResponse> {
|
|
412
|
+
const request = { ...body, loginToken: localStorage.getItem('loginToken') ?? '' };
|
|
413
|
+
return this.http.post<TwoFactorVerifyResponse>(url, request, { withCredentials: true }).pipe(
|
|
414
|
+
tap((res) => { if (res.valid && res.token) this.completeLogin(res.token, res.refreshToken); }),
|
|
399
415
|
);
|
|
400
416
|
}
|
|
401
417
|
|
|
@@ -417,6 +433,14 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
417
433
|
return this.http.post<{ message: string }>(this.trustedDeviceRevokeUrl, { deviceId });
|
|
418
434
|
}
|
|
419
435
|
|
|
436
|
+
getSessions() {
|
|
437
|
+
return this.http.get<ActiveSession[]>(this.sessionListUrl);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
revokeSession(id: number) {
|
|
441
|
+
return this.http.post<void>(this.sessionRevokeUrl, { id });
|
|
442
|
+
}
|
|
443
|
+
|
|
420
444
|
chpass(username: string, new_password: string, old_password: string) {
|
|
421
445
|
return this.http.post<{ status: string }>(this.chpassUrl, { username, old_password, new_password })
|
|
422
446
|
.pipe(tap(() => this.passwordChanged()));
|
|
@@ -458,7 +482,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
458
482
|
* answers. A registration without partner fields leaves `partnerId` 0. */
|
|
459
483
|
confirmRegister(email: string, code: string): Observable<ConfirmRegisterResponse> {
|
|
460
484
|
const params = new HttpParams().set('email', email).set('code', code);
|
|
461
|
-
return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params }).pipe(
|
|
485
|
+
return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params, withCredentials: true }).pipe(
|
|
462
486
|
tap((res) => this.completeLogin(res.token, res.refreshToken)),
|
|
463
487
|
);
|
|
464
488
|
}
|
|
@@ -488,11 +512,20 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
488
512
|
}
|
|
489
513
|
|
|
490
514
|
loadStoredSession() {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
515
|
+
if (!localStorage.getItem('jwt')) return;
|
|
516
|
+
// A signed-in user sent to the login page by the authorization server is handed
|
|
517
|
+
// off without signing in again, unless the server asks for a sign-in.
|
|
518
|
+
const handoff = window.location.pathname.startsWith('/login') && this.handoffPending();
|
|
519
|
+
const prompt = handoff ? new URLSearchParams(window.location.search).get('prompt') : null;
|
|
520
|
+
if (prompt === 'login' || prompt === 'select_account') {
|
|
521
|
+
this.signInPrompt.set(prompt);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
this.adoptStoredToken();
|
|
525
|
+
if (handoff) {
|
|
526
|
+
this.startHandoff();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
496
529
|
this.loadAppData();
|
|
497
530
|
this.initRoutes();
|
|
498
531
|
}
|
|
@@ -500,13 +533,17 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
500
533
|
/** Revoke the refresh token server-side (best effort: the local session is
|
|
501
534
|
* cleared regardless), then land on the login screen. */
|
|
502
535
|
logout() {
|
|
536
|
+
this.signOutHere();
|
|
537
|
+
this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** logout() without navigating, so the login page keeps its `?return=`. */
|
|
541
|
+
signOutHere() {
|
|
503
542
|
this.revokeRefreshToken().subscribe();
|
|
504
543
|
this.clearSession();
|
|
505
|
-
this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
|
|
506
544
|
}
|
|
507
545
|
|
|
508
|
-
private revokeRefreshToken(): Observable<unknown> {
|
|
509
|
-
const refreshToken = localStorage.getItem('refreshToken');
|
|
546
|
+
private revokeRefreshToken(refreshToken = localStorage.getItem('refreshToken')): Observable<unknown> {
|
|
510
547
|
if (!refreshToken) return EMPTY;
|
|
511
548
|
return this.http.post(this.logoutUrl, { refreshToken }).pipe(
|
|
512
549
|
catchError((err) => { console.warn('refresh token revocation failed:', err); return EMPTY; }),
|
|
@@ -549,7 +586,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
549
586
|
* omitted (single-role apps), the role stays unchanged.
|
|
550
587
|
*/
|
|
551
588
|
verifyOtp(req: OtpVerifyRequest, role?: string): Observable<OtpVerifyResponse> {
|
|
552
|
-
return this.http.post<OtpVerifyResponse>(this.otpVerifyUrl, req).pipe(
|
|
589
|
+
return this.http.post<OtpVerifyResponse>(this.otpVerifyUrl, req, { withCredentials: true }).pipe(
|
|
553
590
|
tap((res) => {
|
|
554
591
|
if (res?.token) {
|
|
555
592
|
this.completeLogin(res.token, res.refreshToken);
|
|
@@ -569,7 +606,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
569
606
|
consent?: SignupConsent,
|
|
570
607
|
): Observable<LoginResponseSocial> {
|
|
571
608
|
const payload: SocialLoginRequest = { provider, token: idToken, ...consent };
|
|
572
|
-
return this.http.post<LoginResponseSocial>(this.loginSocialUrl, payload).pipe(
|
|
609
|
+
return this.http.post<LoginResponseSocial>(this.loginSocialUrl, payload, { withCredentials: true }).pipe(
|
|
573
610
|
tap((res) => { if (res?.token) this.completeLogin(res.token, res.refreshToken); }),
|
|
574
611
|
);
|
|
575
612
|
}
|
|
@@ -635,11 +672,13 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
635
672
|
localStorage.removeItem('refreshToken');
|
|
636
673
|
localStorage.removeItem('menu');
|
|
637
674
|
localStorage.removeItem('loginToken');
|
|
675
|
+
localStorage.removeItem('twoFactorMethod');
|
|
638
676
|
this.clearRole();
|
|
639
677
|
this.cache = undefined;
|
|
640
678
|
this.authIndex.clear();
|
|
641
679
|
this.appDataError.set(null);
|
|
642
680
|
this.sessionHandoffError.set(null);
|
|
681
|
+
this.signInPrompt.set(null);
|
|
643
682
|
this.refreshInFlight = null;
|
|
644
683
|
this.landing = null;
|
|
645
684
|
this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
|
package/src/service/rest_url.ts
CHANGED
|
@@ -23,6 +23,8 @@ export const RestURL = {
|
|
|
23
23
|
trustedDeviceListURL: '/api/user/trusted-device/list',
|
|
24
24
|
trustedDeviceRegisterURL: '/api/user/trusted-device/register',
|
|
25
25
|
trustedDeviceRevokeURL: '/api/user/trusted-device/revoke',
|
|
26
|
+
sessionListURL: '/api/user/sessions',
|
|
27
|
+
sessionRevokeURL: '/api/user/sessions/revoke',
|
|
26
28
|
checkoutURL: '/api/billing/checkout',
|
|
27
29
|
subscriptionURL: '/api/billing/subscription',
|
|
28
30
|
cancelSubURL: '/api/billing/subscription/cancel',
|
package/src/util/errors.ts
CHANGED
|
@@ -11,6 +11,12 @@ function problemOf(err: unknown): ProblemShape | undefined {
|
|
|
11
11
|
return body && typeof body === 'object' ? (body as ProblemShape) : undefined;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const codeMessages = new Map([
|
|
15
|
+
['signin_network', 'Sign-in is not allowed from this network.'],
|
|
16
|
+
['signin_network_lockout', 'The allowed networks must include your current address.'],
|
|
17
|
+
['stepup_unavailable', 'A sign-in code cannot be sent right now. Please try again later.'],
|
|
18
|
+
]);
|
|
19
|
+
|
|
14
20
|
function asString(value: unknown): string | undefined {
|
|
15
21
|
return typeof value === 'string' && value ? value : undefined;
|
|
16
22
|
}
|
|
@@ -58,7 +64,7 @@ export function errorDetail(err: unknown, fallback: string, statuses?: readonly
|
|
|
58
64
|
}
|
|
59
65
|
const problem = problemOf(err);
|
|
60
66
|
if (problem) {
|
|
61
|
-
const message = asString(problem.detail) ?? asString(problem.title) ?? asString(problem.message);
|
|
67
|
+
const message = codeMessages.get(asString(problem.code) ?? '') ?? asString(problem.detail) ?? asString(problem.title) ?? asString(problem.message);
|
|
62
68
|
if (message) return message;
|
|
63
69
|
}
|
|
64
70
|
if (err instanceof Error && err.message) {
|