@auth-link/web 0.0.2 → 0.0.4
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/CHANGELOG.md +12 -0
- package/README.md +802 -9
- package/docs/README.en.md +11 -3
- package/docs/README.ja.md +11 -3
- package/docs/README.zh-CN.md +11 -3
- package/docs/go-vue.en.md +256 -0
- package/docs/go-vue.ja.md +256 -0
- package/docs/go-vue.zh-CN.md +260 -0
- package/examples/vue-cms.vue +109 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
Responsive dialogs for **AuthLink phone binding and mobile confirmation**, with direct function calls and React / Vue components. Display binding QR codes, synchronized six-digit matching codes, countdowns and confirmation results in any web application. No particular site platform is required.
|
|
8
8
|
|
|
9
|
+
### Go backend + Vue walkthrough
|
|
10
|
+
|
|
11
|
+
Start with [Go + Vue: login, binding and confirmed publication](#go-vue-cms-en) and the `examples/vue-cms.vue`.
|
|
12
|
+
|
|
13
|
+
The flow is site login → Go `/api/session` returns authenticated/csrf → configure the cross-origin client with `csrfToken: () => session.csrf` → create bind/confirm → Go consumes, validates and commits the receipt → Vue dismisses the dialog → a separate business API checks the saved receipt and publishes. `baseUrl` targets Go; `authLinkOrigin` identifies AuthLink. Tenant keys stay in Go. Use your own fetch plus `validateTicket` for post/version fields; SDK create() sends purpose only.
|
|
14
|
+
|
|
9
15
|
### Features
|
|
10
16
|
|
|
11
17
|
- Phone-binding QR codes generated locally in the browser.
|
|
@@ -64,12 +70,12 @@ export function readCsrfTokenFromYourCms(): string {
|
|
|
64
70
|
export const client = createAuthLinkClient({
|
|
65
71
|
baseUrl: '/api/authlink',
|
|
66
72
|
csrfToken: readCsrfTokenFromYourCms,
|
|
67
|
-
//
|
|
68
|
-
authLinkOrigin: 'https://
|
|
73
|
+
// AuthLink production API origin.
|
|
74
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
69
75
|
});
|
|
70
76
|
```
|
|
71
77
|
|
|
72
|
-
|
|
78
|
+
AuthLink production uses `https://api.getauthlink.com`. Keep `baseUrl` pointed at your own application backend; the AuthLink website domain is not an API endpoint. For a separately hosted AuthLink service, use that service’s configured origin. Create requests only in user event handlers, as in the React example below.
|
|
73
79
|
|
|
74
80
|
### Separate frontend and backend origins (since 0.0.2)
|
|
75
81
|
|
|
@@ -81,6 +87,8 @@ The SDK validates the destination before evaluating credentials and refuses redi
|
|
|
81
87
|
|
|
82
88
|
This complete initialization function uses an example `GET /api/session` endpoint that **your application implements**, returning `{ "csrfToken": "current-session-token" }` for the authenticated session with `Cache-Control: no-store` and CORS enabled. Replace that path with your existing session endpoint.
|
|
83
89
|
|
|
90
|
+
`csrfToken` is the JSON property chosen for this generic example. The Go CMS walkthrough uses `{ authenticated: true, csrf: '…' }` instead; map session.csrf to the SDK csrfToken callback without renaming your backend response.
|
|
91
|
+
|
|
84
92
|
```ts
|
|
85
93
|
import { createAuthLinkClient } from '@auth-link/web';
|
|
86
94
|
|
|
@@ -398,12 +406,273 @@ React and Vue accept `className` for the dialog. Vue also accepts standard strin
|
|
|
398
406
|
|
|
399
407
|
MIT. See `LICENSE` for the full license text and `THIRD_PARTY_NOTICES.md` for bundled third-party attribution.
|
|
400
408
|
|
|
409
|
+
### Go Vue CMS: en
|
|
410
|
+
|
|
411
|
+
This walkthrough follows a working Go CMS and Vue 3 integration. Example origins replace local deployment details; no test users, passwords or tenant credentials are required by these examples. Your website implements login, persistence and business routes. The web package displays and observes requests; the Go SDK calls AuthLink and validates receipts.
|
|
412
|
+
|
|
413
|
+
#### 1. Install and separate the three origins
|
|
414
|
+
|
|
415
|
+
| Role | Example origin | Configuration |
|
|
416
|
+
| --- | --- | --- |
|
|
417
|
+
| Vue | `https://cms.example.com` | `apiOrigin` is the Go backend below |
|
|
418
|
+
| Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
|
|
419
|
+
| AuthLink API | `https://api.getauthlink.com` | Go SDK `Origin`; browser `authLinkOrigin` |
|
|
420
|
+
|
|
421
|
+
The AuthLink API uses production origin `https://api.getauthlink.com`; the Vue and Go CMS origins remain examples for your own website, not the AuthLink marketing site.
|
|
422
|
+
|
|
423
|
+
Register the Vue site's origin for the tenant. The Go SDK `Origin` and the API origin in an exported credential JSON identify the AuthLink API. Browser `baseUrl` targets your Go application API. Keep TenantID/APIKey in private backend configuration; public runtime configuration contains origins only.
|
|
424
|
+
|
|
425
|
+
```sh
|
|
426
|
+
npm install @auth-link/web@latest vue
|
|
427
|
+
# In your Go backend module, after unpacking the supplied Go SDK:
|
|
428
|
+
go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
|
|
429
|
+
go mod edit -require=authlink.local/sdk@v0.0.0
|
|
430
|
+
go mod tidy
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The Go SDK is supplied as source or a Go SDK archive from your AuthLink website, not a public Go module or an npm backend. Preserve its own go.mod and import `authlink "authlink.local/sdk"`. Initialize it with trusted `authlink.Config{Origin: authOrigin, TenantID: tenantID, APIKey: apiKey}` through `authlink.New`.
|
|
434
|
+
|
|
435
|
+
#### 2. Log in to the website and load its session
|
|
436
|
+
|
|
437
|
+
Your login route (the reference CMS uses `POST /api/login`) issues a Secure, HttpOnly session cookie. Fetch Go's `GET /api/session` with `credentials:'include'`; require `authenticated:true` and the current `csrf` value before opening requests. Otherwise return to your own login flow.
|
|
438
|
+
|
|
439
|
+
Here the JSON property is **csrf**, while the SDK option is **csrfToken**: use `csrfToken: () => session.value?.csrf ?? ''`. A backend is free to name the response property differently. Go generates and saves the token for that session and validates X-CSRF-Token on creation AND result polling. Never substitute a tenant key or matching number.
|
|
440
|
+
|
|
441
|
+
Two HTTPS subdomains under the same site are cross-origin but same-site. The reference API sets a host-only cookie with `SameSite=Strict; Secure; HttpOnly; Path=/`, without Domain. Requests still target the API host. For a truly cross-site setup, account for browser cookie policy as well as CORS.
|
|
442
|
+
|
|
443
|
+
#### 3. Configure Go CORS and application routes
|
|
444
|
+
|
|
445
|
+
```http
|
|
446
|
+
Access-Control-Allow-Origin: https://cms.example.com
|
|
447
|
+
Access-Control-Allow-Credentials: true
|
|
448
|
+
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
|
|
449
|
+
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
|
|
450
|
+
Vary: Origin
|
|
451
|
+
Cache-Control: no-store
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Return these headers only for an exact allowed Vue origin, including allowed-origin error responses. Validate the origin/method/headers of OPTIONS and return 204 before requiring login or CSRF; authenticate and check CSRF/ownership on actual requests. Do not reflect arbitrary origins or use `*`. PUT is for CMS draft editing; narrow it to the routes you implement.
|
|
455
|
+
|
|
456
|
+
| Application route | Input | Output |
|
|
457
|
+
| --- | --- | --- |
|
|
458
|
+
| `GET /api/session` | — | `{authenticated:true, csrf:"…", subject:"…", username:"…"}`; logged out: `{authenticated:false}` |
|
|
459
|
+
| `POST /api/authlink/requests` | `{purpose:"bind"}` or `{purpose:"confirm",postId,version}`; `Idempotency-Key` | Public `Ticket` |
|
|
460
|
+
| `POST /api/authlink/requests/{id}/result` | `{}`; current session + CSRF | Pending status/displayCode or verified `confirmed` receipt |
|
|
461
|
+
| `POST /api/posts/{id}/publish` | `{requestId,version}`; current session + CSRF | `{ok:true}` after checking the saved confirmation and intent |
|
|
462
|
+
|
|
463
|
+
Your Go application implements all four routes. postId/version and the publication route are application-specific conventions, not AuthLink fields.
|
|
464
|
+
|
|
465
|
+
#### 4. Persist before creation; commit verification before confirmed
|
|
466
|
+
|
|
467
|
+
```text
|
|
468
|
+
Vue → Go /api/session → current session + csrf
|
|
469
|
+
Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
|
|
470
|
+
Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
|
|
471
|
+
Go commits verified receipt → Vue confirmed event
|
|
472
|
+
Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Derive the stable user ID from the authenticated session. Before an upstream call, validate purpose/idempotency and optional post ownership/version; independently generate state and a consume key, then persist CreateInput, the browser creation key, session/user and publication intent. A network retry loads the same record and keys. Enforce unique (session, creation key) and reject reuse for a different purpose/post/version.
|
|
476
|
+
|
|
477
|
+
Save the returned ticket, requestId and original expiresAt; omit optional qrSvg before returning public data. The result route locks the request owned by the current session and reuses any saved receipt. Call Status, then Consume only for approved/consumed using Expected reconstructed from saved data. Persist the verified public receipt, session confirmation and audit transactionally with unique requestId/verificationId constraints; commit before returning confirmed. Pending results preserve displayCode without caching beyond its validity.
|
|
478
|
+
|
|
479
|
+
This is the SDK-call layer, **not a complete HTTP server**. Your application must implement the database/transaction and ownership steps described above and in the comments.
|
|
480
|
+
|
|
481
|
+
```go
|
|
482
|
+
package cmsauth
|
|
483
|
+
|
|
484
|
+
import (
|
|
485
|
+
"context"
|
|
486
|
+
authlink "authlink.local/sdk"
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
// Load this record from your database for the authenticated user/session.
|
|
490
|
+
// Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
|
|
491
|
+
// Intent (post ID/version), ownership and unique constraints belong in your DB.
|
|
492
|
+
type StoredRequest struct {
|
|
493
|
+
Input authlink.CreateInput
|
|
494
|
+
CreateKey string
|
|
495
|
+
ConsumeKey string
|
|
496
|
+
Ticket authlink.Ticket
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// On network retries, load the SAME record; do not regenerate State or keys.
|
|
500
|
+
func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
|
|
501
|
+
return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
type PublicConfirmation struct {
|
|
505
|
+
Status string `json:"status"`
|
|
506
|
+
RequestID string `json:"requestId"`
|
|
507
|
+
VerificationID string `json:"verificationId"`
|
|
508
|
+
Purpose string `json:"purpose"`
|
|
509
|
+
ExpiresAt string `json:"expiresAt"`
|
|
510
|
+
AuthenticationPolicy string `json:"authenticationPolicy"`
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Caller checks session/CSRF/ownership, locks the saved record and first returns
|
|
514
|
+
// any already-saved receipt. For a new confirmed result, caller must persist
|
|
515
|
+
// the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
|
|
516
|
+
func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
|
|
517
|
+
status, err := sdk.Status(ctx, saved.Ticket.RequestID)
|
|
518
|
+
if err != nil { return nil, err }
|
|
519
|
+
if status.Status != "approved" && status.Status != "consumed" { return status, nil }
|
|
520
|
+
result, err := sdk.Consume(ctx, authlink.Expected{
|
|
521
|
+
RequestID: saved.Ticket.RequestID,
|
|
522
|
+
ExternalUserID: saved.Input.ExternalUserID,
|
|
523
|
+
Purpose: saved.Input.Purpose,
|
|
524
|
+
State: saved.Input.State,
|
|
525
|
+
ExpiresAt: saved.Ticket.ExpiresAt,
|
|
526
|
+
}, saved.ConsumeKey)
|
|
527
|
+
if err != nil { return nil, err }
|
|
528
|
+
return PublicConfirmation{
|
|
529
|
+
Status: "confirmed", RequestID: result.RequestID,
|
|
530
|
+
VerificationID: result.VerificationID, Purpose: result.Purpose,
|
|
531
|
+
ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
|
|
532
|
+
}, nil
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
#### 5. Vue lifecycle and business-bound requests
|
|
537
|
+
|
|
538
|
+
The complete file is also shipped as `examples/vue-cms.vue`. Mount after site login with trusted apiOrigin/authLinkOrigin and an optional saved draft `post={id,version}`. Key the parent component by the current authenticated session identity. Unmount on logout, login rotation or account change; clear old tickets, creation keys and pending publication before mounting a new instance.
|
|
539
|
+
|
|
540
|
+
```vue
|
|
541
|
+
<script setup lang="ts">
|
|
542
|
+
import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
|
543
|
+
import { createAuthLinkClient, requestKey, validateTicket } from '@auth-link/web';
|
|
544
|
+
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
545
|
+
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
546
|
+
import '@auth-link/web/styles.css';
|
|
547
|
+
|
|
548
|
+
type Post = { id: string; version: number };
|
|
549
|
+
type Publication = Post & { requestId: string };
|
|
550
|
+
// Supply trusted deployment configuration. Mount after site login and remount
|
|
551
|
+
// with :key="session.subject" after login, logout or account/session changes.
|
|
552
|
+
const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
|
|
553
|
+
const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
|
|
554
|
+
const ticket = shallowRef<Ticket | null>(null);
|
|
555
|
+
const publication = shallowRef<Post | null>(null);
|
|
556
|
+
const retryPublish = shallowRef<Publication | null>(null);
|
|
557
|
+
const busy = ref(false), message = ref('');
|
|
558
|
+
let creation: { signature: string; key: string } | null = null;
|
|
559
|
+
const lifetime = new AbortController();
|
|
560
|
+
const client = markRaw(createAuthLinkClient({
|
|
561
|
+
baseUrl: `${props.apiOrigin}/api/authlink`,
|
|
562
|
+
allowedBackendOrigins: [props.apiOrigin],
|
|
563
|
+
credentials: 'include',
|
|
564
|
+
csrfToken: () => session.value?.csrf ?? '',
|
|
565
|
+
authLinkOrigin: props.authLinkOrigin,
|
|
566
|
+
}));
|
|
567
|
+
|
|
568
|
+
async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
|
|
569
|
+
const response = await fetch(`${props.apiOrigin}${path}`, {
|
|
570
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
571
|
+
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
572
|
+
signal: lifetime.signal,
|
|
573
|
+
headers: body === undefined ? {} : {
|
|
574
|
+
'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
|
|
575
|
+
},
|
|
576
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
577
|
+
});
|
|
578
|
+
const value = await response.json();
|
|
579
|
+
if (response.status === 401) {
|
|
580
|
+
session.value = null; closeTicket(); retryPublish.value = null; creation = null;
|
|
581
|
+
}
|
|
582
|
+
if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
|
|
583
|
+
return value;
|
|
584
|
+
}
|
|
585
|
+
function closeTicket() { ticket.value = null; publication.value = null; }
|
|
586
|
+
function showError(error: Error) { message.value = error.message; }
|
|
587
|
+
function finish(text: string) { closeTicket(); message.value = text; }
|
|
588
|
+
|
|
589
|
+
async function start(purpose: Mode, post?: Post) {
|
|
590
|
+
if (busy.value || ticket.value || !session.value?.authenticated) return;
|
|
591
|
+
busy.value = true; message.value = ''; retryPublish.value = null;
|
|
592
|
+
const intent = post ? { id: post.id, version: post.version } : null;
|
|
593
|
+
const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
|
|
594
|
+
try {
|
|
595
|
+
if (creation?.signature !== signature) creation = { signature, key: requestKey() };
|
|
596
|
+
// SDK create() sends only {purpose}. Business fields use your own API.
|
|
597
|
+
const value = intent
|
|
598
|
+
? validateTicket(await api('/api/authlink/requests', {
|
|
599
|
+
purpose, postId: intent.id, version: intent.version,
|
|
600
|
+
}, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
|
|
601
|
+
: await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
|
|
602
|
+
if (lifetime.signal.aborted) return;
|
|
603
|
+
publication.value = intent; ticket.value = value; creation = null;
|
|
604
|
+
} catch (error) {
|
|
605
|
+
if (!lifetime.signal.aborted) showError(error as Error);
|
|
606
|
+
// Retain creation.key for a retry of the SAME purpose/post/version.
|
|
607
|
+
} finally { busy.value = false; }
|
|
608
|
+
}
|
|
609
|
+
async function publish() {
|
|
610
|
+
const intent = retryPublish.value;
|
|
611
|
+
if (!intent || busy.value) return;
|
|
612
|
+
busy.value = true;
|
|
613
|
+
try {
|
|
614
|
+
await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
|
|
615
|
+
retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
|
|
616
|
+
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
617
|
+
finally { busy.value = false; }
|
|
618
|
+
}
|
|
619
|
+
async function confirmed(result: Confirmation) {
|
|
620
|
+
const intent = publication.value;
|
|
621
|
+
closeTicket();
|
|
622
|
+
if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
|
|
623
|
+
else message.value = 'Verified. Reload account status from the backend.';
|
|
624
|
+
}
|
|
625
|
+
onMounted(async () => {
|
|
626
|
+
try {
|
|
627
|
+
const value = await api('/api/session');
|
|
628
|
+
if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
|
|
629
|
+
throw new Error('LOGIN_REQUIRED');
|
|
630
|
+
}
|
|
631
|
+
session.value = value;
|
|
632
|
+
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
633
|
+
});
|
|
634
|
+
onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
|
|
635
|
+
</script>
|
|
636
|
+
|
|
637
|
+
<template>
|
|
638
|
+
<button :disabled="busy || !!ticket || !session?.authenticated" @click="start('bind')">Bind phone</button>
|
|
639
|
+
<button :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm')">Confirm</button>
|
|
640
|
+
<button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
|
|
641
|
+
<button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
|
|
642
|
+
<AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
|
|
643
|
+
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
644
|
+
@expired="finish('Expired; start a new request')" @error="showError" />
|
|
645
|
+
<AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
|
|
646
|
+
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
647
|
+
@expired="finish('Expired; start a new request')" @error="showError" />
|
|
648
|
+
<p role="status">{{ message }}</p>
|
|
649
|
+
</template>
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
`client.create(purpose, {idempotencyKey})` sends purpose only. To bind confirmation to a post/version, use your own fetch with those business fields, validate the result with `validateTicket(response, 'confirm', authLinkOrigin)`, and reuse the official client for observation. Keep the same creation key after an uncertain failure, and change it when purpose/post/version changes. Keep tickets in shallowRef and clients in markRaw.
|
|
653
|
+
|
|
654
|
+
Mounting a dialog observes an existing request; never create from mounted/watch. Clear the ticket on close/rejection/expiry. Leave the dialog mounted on network errors to retry that request. On confirmation, dismiss it and call the independent publish route; preserve the same requestId/post/version if that call fails. Reload actual account/post state from the backend. The sample omits your login form, post list and application error translations.
|
|
655
|
+
|
|
656
|
+
#### 6. Recheck confirmation in the publish transaction
|
|
657
|
+
|
|
658
|
+
The browser confirmed event is not authorization. Go must load the verified receipt for the current session and check purpose=confirm, intent=publish, post ownership, the same postId/version, draft state and expiry on first application. Update the post and mark the intent applied atomically. A retry of that same applied operation returns success without duplicate publication/audit. Another post or edited version requires a new confirmation. Do not add action, transactionHash or document content to the AuthLink receipt.
|
|
659
|
+
|
|
660
|
+
#### Acceptance sequence
|
|
661
|
+
|
|
662
|
+
Site login → session bootstrap → backend AccountStatus (the reference CMS exposes it through overview) → bind if unbound → phone approval → consume and refresh account status → save draft → confirm that post/version → phone approval → commit receipt → publish → verify duplicate retry, wrong version, rejection and expiry. Real phone approval remains a separate acceptance step; compilation and mock HTTP checks do not replace it.
|
|
663
|
+
|
|
401
664
|
---
|
|
402
665
|
|
|
403
666
|
## 简体中文
|
|
404
667
|
|
|
405
668
|
**AuthLink 官方前端组件包**,帮助网站接入手机绑定与 App 确认流程,支持 React 18/19、Vue 3.3+、TypeScript 和 ESM。
|
|
406
669
|
|
|
670
|
+
### Go + Vue 完整接入
|
|
671
|
+
|
|
672
|
+
按实际 CMS 流程接入,推荐先读 [Go + Vue:从登录、绑定到确认发布](#go-vue-cms-zh-cn),完整组件见 `examples/vue-cms.vue`。
|
|
673
|
+
|
|
674
|
+
顺序是:网站登录 → Go `/api/session` 返回 `authenticated` 与 `csrf` → Vue 通过 `csrfToken: () => session.csrf` 配置跨域客户端 → 创建 bind/confirm → Go 验证并消费结果后落库 → Vue 清理弹窗 → 独立业务接口再校验已保存回执并发布。`baseUrl` 是 Go 业务 API,`authLinkOrigin` 是 AuthLink API;租户密钥始终留在 Go 后台。带文章 ID/版本的确认通过网站自己的 fetch 创建并用 `validateTicket` 校验,SDK 的 `create` 只发送 purpose。
|
|
675
|
+
|
|
407
676
|
|
|
408
677
|
### 功能
|
|
409
678
|
|
|
@@ -475,12 +744,12 @@ export function readCsrfTokenFromYourCms(): string {
|
|
|
475
744
|
export const client = createAuthLinkClient({
|
|
476
745
|
baseUrl: '/api/authlink',
|
|
477
746
|
csrfToken: readCsrfTokenFromYourCms,
|
|
478
|
-
//
|
|
479
|
-
authLinkOrigin: 'https://
|
|
747
|
+
// AuthLink production API origin.
|
|
748
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
480
749
|
});
|
|
481
750
|
```
|
|
482
751
|
|
|
483
|
-
|
|
752
|
+
AuthLink 生产服务来源为 `https://api.getauthlink.com`。`baseUrl` 仍填写你自己的网站业务后台,官网域名不用于接口调用;连接独立部署的 AuthLink 服务时才按其配置调整来源。SDK 会在每次请求时调用读取函数,自动把值放入 `X-CSRF-Token`,并携带同源登录 cookie;前端不需要读取 HttpOnly 登录 cookie。若网站框架默认使用其他 CSRF 头名,可用 `csrfHeaderName` 配成后台期望的头名,或者实现自定义 `AuthLinkClient`。
|
|
484
753
|
|
|
485
754
|
`create` 只传用途,网站账号由后台登录 session 决定。客户端通常只初始化一次;用户点击按钮时才创建请求。完整调用见下方 React 示例,不要把 `client.create()` 放在模块加载、React effect 或 Vue watch 中。
|
|
486
755
|
|
|
@@ -494,6 +763,8 @@ export const client = createAuthLinkClient({
|
|
|
494
763
|
|
|
495
764
|
下面给出完整的初始化函数。`GET /api/session` 是接入网站自己实现的示例会话接口,不是包或 AuthLink 服务自带的接口;它应根据现有登录 cookie 返回 `{ "csrfToken": "当前会话的真实CSRF token" }`,设置 `Cache-Control: no-store` 并配置下文的 CORS。部署时将路径换成网站实际的会话初始化接口。
|
|
496
765
|
|
|
766
|
+
这里的 `csrfToken` 是通用示例选定的 JSON 字段名。Go CMS 实例实际返回 `{ authenticated: true, csrf: '…' }`;读取 `session.csrf` 并交给同名 SDK 配置回调即可,无须改动后台响应。完整映射见上方 Go + Vue 指南。
|
|
767
|
+
|
|
497
768
|
```ts
|
|
498
769
|
import { createAuthLinkClient } from '@auth-link/web';
|
|
499
770
|
|
|
@@ -866,12 +1137,277 @@ React 18.2+ / 19、Vue 3.3+、TypeScript、ESM。React 和 Vue 是可选 peer,
|
|
|
866
1137
|
|
|
867
1138
|
MIT。完整许可文本见 `LICENSE`,打包包含的第三方库归属说明见 `THIRD_PARTY_NOTICES.md`。
|
|
868
1139
|
|
|
1140
|
+
### Go Vue CMS: zh-CN
|
|
1141
|
+
|
|
1142
|
+
本指南按实际 Go CMS + Vue 3 的接入顺序整理。下面使用可替换的示例域名,不依赖某台电脑的配置、测试账号、密码或租户密钥。网站负责登录、数据库和业务接口;Web SDK 负责展示和观察请求,Go SDK 负责后台调用与回执校验。
|
|
1143
|
+
|
|
1144
|
+
#### 1. 区分三个来源并安装 SDK
|
|
1145
|
+
|
|
1146
|
+
| 角色 | 示例来源 | 配置位置 |
|
|
1147
|
+
| --- | --- | --- |
|
|
1148
|
+
| Vue | `https://cms.example.com` | `apiOrigin` 指向下方的 Go 后台 |
|
|
1149
|
+
| Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
|
|
1150
|
+
| AuthLink API | `https://api.getauthlink.com` | Go SDK 的 `Origin`;浏览器的 `authLinkOrigin` |
|
|
1151
|
+
|
|
1152
|
+
AuthLink API 使用生产地址 `https://api.getauthlink.com`;Vue 网站和 Go CMS 的域名仍是接入方自己的示例,不能替换成 AuthLink 官网域名。
|
|
1153
|
+
|
|
1154
|
+
登记租户的站点 origin 是 Vue 网站来源;Go SDK 的 `Origin` 和凭据导出 JSON 中的 API origin 是 AuthLink API 来源。浏览器的 `baseUrl` 指向 Go 业务后台,不能指向 AuthLink 租户 API。公开运行配置只包含这三个 origin;`TenantID`、`APIKey` 保留在 Go 私密配置或环境变量中。
|
|
1155
|
+
|
|
1156
|
+
```sh
|
|
1157
|
+
npm install @auth-link/web@latest vue
|
|
1158
|
+
# In your Go backend module, after unpacking the supplied Go SDK:
|
|
1159
|
+
go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
|
|
1160
|
+
go mod edit -require=authlink.local/sdk@v0.0.0
|
|
1161
|
+
go mod tidy
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
Go SDK 当前随源码或官网的 Go SDK 源码包交付,保留 `third_party/authlink-go/go.mod`,导入 `authlink "authlink.local/sdk"`;这不是公共 `go get` 地址,也不是 npm 包内置的后台。用可信后台配置调用 `authlink.New(authlink.Config{Origin: authOrigin, TenantID: tenantID, APIKey: apiKey})`。
|
|
1165
|
+
|
|
1166
|
+
#### 2. 先完成网站登录,再读取会话
|
|
1167
|
+
|
|
1168
|
+
本站现有登录接口(参考 CMS 为 `POST /api/login`)验证用户后签发 HttpOnly、Secure 的 session Cookie。Vue 用 `credentials:'include'` 调用 Go 的 `GET /api/session`。已登录时返回 `authenticated:true` 与 `csrf`;未登录时先回到网站登录流程。
|
|
1169
|
+
|
|
1170
|
+
`csrf` 是本 CMS 的响应字段名;SDK 配置项仍叫 `csrfToken`,写成 `csrfToken: () => session.value?.csrf ?? ''` 即可。SDK 不要求你的后台一定返回名为 `csrfToken` 的字段。令牌由 Go 为当前 session 生成并保存,浏览器只读取,不能用租户密钥或手机核对码代替。创建和结果查询均需验证 `X-CSRF-Token` 与同一 session 的值一致。
|
|
1171
|
+
|
|
1172
|
+
Vue 与 API 使用同一 HTTPS 主域名下的两个子域名时,是跨 origin、同 site。参考 CMS 的 host-only Cookie 不设置 `Domain`,使用 `SameSite=Strict; Secure; HttpOnly; Path=/`;请求目标仍为 API 域名。真正跨 site 时还需按浏览器 Cookie 策略配置,不能只依靠 CORS。
|
|
1173
|
+
|
|
1174
|
+
#### 3. Go 配置 CORS 与业务路由
|
|
1175
|
+
|
|
1176
|
+
```http
|
|
1177
|
+
Access-Control-Allow-Origin: https://cms.example.com
|
|
1178
|
+
Access-Control-Allow-Credentials: true
|
|
1179
|
+
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
|
|
1180
|
+
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
|
|
1181
|
+
Vary: Origin
|
|
1182
|
+
Cache-Control: no-store
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
只为精确允许的 Vue origin 返回这些 CORS 头;允许来源的正常和错误响应都要携带。`OPTIONS` 先验证来源、方法和请求头,成功返回 204,不要求登录或 CSRF;随后实际请求才验证登录、来源、CSRF 和归属。不能用 `*` 或无条件反射来源。上面包含 CMS 草稿编辑所用的 PUT;只接入 SDK 的站点可按实际路由收窄。
|
|
1186
|
+
|
|
1187
|
+
| 网站自己的路由 | 请求 | 返回 |
|
|
1188
|
+
| --- | --- | --- |
|
|
1189
|
+
| `GET /api/session` | — | `{authenticated:true, csrf:"…", subject:"…", username:"…"}`;未登录: `{authenticated:false}` |
|
|
1190
|
+
| `POST /api/authlink/requests` | `{purpose:"bind"}` 或 `{purpose:"confirm",postId,version}`; `Idempotency-Key` | 公开 `Ticket` |
|
|
1191
|
+
| `POST /api/authlink/requests/{id}/result` | `{}`;当前 session + CSRF | 待处理状态/displayCode 或已验证的 `confirmed` 回执 |
|
|
1192
|
+
| `POST /api/posts/{id}/publish` | `{requestId,version}`; 当前 session + CSRF | `{ok:true}` 校验已保存的确认记录和业务意图后返回 |
|
|
1193
|
+
|
|
1194
|
+
上述路由由 Go CMS 实现,不是安装 Web SDK 后自动出现的接口。`postId` / `version` / `/publish` 是本例业务约定,不是 AuthLink 平台字段。
|
|
1195
|
+
|
|
1196
|
+
#### 4. 创建前落库,消费后再返回 confirmed
|
|
1197
|
+
|
|
1198
|
+
```text
|
|
1199
|
+
Vue → Go /api/session → current session + csrf
|
|
1200
|
+
Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
|
|
1201
|
+
Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
|
|
1202
|
+
Go commits verified receipt → Vue confirmed event
|
|
1203
|
+
Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
Go 从可信 session 取稳定用户 ID,不能接受浏览器自报的 `externalUserId`。创建顺序:
|
|
1207
|
+
|
|
1208
|
+
1. 校验 purpose、创建幂等键,以及文章归属和当前版本(若有)。
|
|
1209
|
+
2. 生成独立随机 state 和消费幂等键;把原创建键、CreateInput、当前用户/session 和业务意图落库,再向 AuthLink 发请求。网络结果不明时从数据库恢复同一记录、输入和键。
|
|
1210
|
+
3. 调用 `CreateRequest`,保存返回的 requestId、原始 expiresAt 与 ticket,去掉可选 qrSvg 后返回公开 ticket。对同一 session + 创建键加唯一约束,键复用到其他 purpose / 文章 / 版本时返回冲突。
|
|
1211
|
+
4. 结果接口按 requestId + 当前 session 加锁读取记录;已保存回执直接复用。用 `Status` 查询,只有 approved / consumed 才以保存的 `Expected` 和消费键调用 `Consume`。SDK 校验失败不能返回成功。
|
|
1212
|
+
5. 在事务里保存回执、用户/session 确认记录和审计,requestId / verificationId 建唯一约束,提交成功后才返回 `confirmed`。待处理状态保留原 displayCode,不跨数字期限缓存。
|
|
1213
|
+
|
|
1214
|
+
下面是 SDK 调用层代码,**不是包含登录、数据库和路由的完整 HTTP 服务**;注释中的落库与事务必须由你的 Go 后台实现。初始化客户端、构造 Input、所有权检查及请求记录的读写按上述步骤完成。
|
|
1215
|
+
|
|
1216
|
+
```go
|
|
1217
|
+
package cmsauth
|
|
1218
|
+
|
|
1219
|
+
import (
|
|
1220
|
+
"context"
|
|
1221
|
+
authlink "authlink.local/sdk"
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
// Load this record from your database for the authenticated user/session.
|
|
1225
|
+
// Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
|
|
1226
|
+
// Intent (post ID/version), ownership and unique constraints belong in your DB.
|
|
1227
|
+
type StoredRequest struct {
|
|
1228
|
+
Input authlink.CreateInput
|
|
1229
|
+
CreateKey string
|
|
1230
|
+
ConsumeKey string
|
|
1231
|
+
Ticket authlink.Ticket
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// On network retries, load the SAME record; do not regenerate State or keys.
|
|
1235
|
+
func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
|
|
1236
|
+
return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
type PublicConfirmation struct {
|
|
1240
|
+
Status string `json:"status"`
|
|
1241
|
+
RequestID string `json:"requestId"`
|
|
1242
|
+
VerificationID string `json:"verificationId"`
|
|
1243
|
+
Purpose string `json:"purpose"`
|
|
1244
|
+
ExpiresAt string `json:"expiresAt"`
|
|
1245
|
+
AuthenticationPolicy string `json:"authenticationPolicy"`
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Caller checks session/CSRF/ownership, locks the saved record and first returns
|
|
1249
|
+
// any already-saved receipt. For a new confirmed result, caller must persist
|
|
1250
|
+
// the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
|
|
1251
|
+
func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
|
|
1252
|
+
status, err := sdk.Status(ctx, saved.Ticket.RequestID)
|
|
1253
|
+
if err != nil { return nil, err }
|
|
1254
|
+
if status.Status != "approved" && status.Status != "consumed" { return status, nil }
|
|
1255
|
+
result, err := sdk.Consume(ctx, authlink.Expected{
|
|
1256
|
+
RequestID: saved.Ticket.RequestID,
|
|
1257
|
+
ExternalUserID: saved.Input.ExternalUserID,
|
|
1258
|
+
Purpose: saved.Input.Purpose,
|
|
1259
|
+
State: saved.Input.State,
|
|
1260
|
+
ExpiresAt: saved.Ticket.ExpiresAt,
|
|
1261
|
+
}, saved.ConsumeKey)
|
|
1262
|
+
if err != nil { return nil, err }
|
|
1263
|
+
return PublicConfirmation{
|
|
1264
|
+
Status: "confirmed", RequestID: result.RequestID,
|
|
1265
|
+
VerificationID: result.VerificationID, Purpose: result.Purpose,
|
|
1266
|
+
ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
|
|
1267
|
+
}, nil
|
|
1268
|
+
}
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
#### 5. Vue 组件:稳定 ticket、按业务绑定创建、清理与重试
|
|
1272
|
+
|
|
1273
|
+
完整文件也包含在包内 `examples/vue-cms.vue`。网站登录后挂载它,传入可信 `apiOrigin`、`authLinkOrigin`,可选传入后台加载的草稿 `post={id,version}`。父层用当前登录 session 的标识做组件 key,退出、重新登录或切换账号时先卸载旧组件并清空会话、旧 ticket、幂等键和待发布操作,再挂载新组件。不要只改用户名后继续使用旧 ticket。
|
|
1274
|
+
|
|
1275
|
+
```vue
|
|
1276
|
+
<script setup lang="ts">
|
|
1277
|
+
import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
|
1278
|
+
import { createAuthLinkClient, requestKey, validateTicket } from '@auth-link/web';
|
|
1279
|
+
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
1280
|
+
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
1281
|
+
import '@auth-link/web/styles.css';
|
|
1282
|
+
|
|
1283
|
+
type Post = { id: string; version: number };
|
|
1284
|
+
type Publication = Post & { requestId: string };
|
|
1285
|
+
// Supply trusted deployment configuration. Mount after site login and remount
|
|
1286
|
+
// with :key="session.subject" after login, logout or account/session changes.
|
|
1287
|
+
const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
|
|
1288
|
+
const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
|
|
1289
|
+
const ticket = shallowRef<Ticket | null>(null);
|
|
1290
|
+
const publication = shallowRef<Post | null>(null);
|
|
1291
|
+
const retryPublish = shallowRef<Publication | null>(null);
|
|
1292
|
+
const busy = ref(false), message = ref('');
|
|
1293
|
+
let creation: { signature: string; key: string } | null = null;
|
|
1294
|
+
const lifetime = new AbortController();
|
|
1295
|
+
const client = markRaw(createAuthLinkClient({
|
|
1296
|
+
baseUrl: `${props.apiOrigin}/api/authlink`,
|
|
1297
|
+
allowedBackendOrigins: [props.apiOrigin],
|
|
1298
|
+
credentials: 'include',
|
|
1299
|
+
csrfToken: () => session.value?.csrf ?? '',
|
|
1300
|
+
authLinkOrigin: props.authLinkOrigin,
|
|
1301
|
+
}));
|
|
1302
|
+
|
|
1303
|
+
async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
|
|
1304
|
+
const response = await fetch(`${props.apiOrigin}${path}`, {
|
|
1305
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
1306
|
+
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
1307
|
+
signal: lifetime.signal,
|
|
1308
|
+
headers: body === undefined ? {} : {
|
|
1309
|
+
'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
|
|
1310
|
+
},
|
|
1311
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1312
|
+
});
|
|
1313
|
+
const value = await response.json();
|
|
1314
|
+
if (response.status === 401) {
|
|
1315
|
+
session.value = null; closeTicket(); retryPublish.value = null; creation = null;
|
|
1316
|
+
}
|
|
1317
|
+
if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
|
|
1318
|
+
return value;
|
|
1319
|
+
}
|
|
1320
|
+
function closeTicket() { ticket.value = null; publication.value = null; }
|
|
1321
|
+
function showError(error: Error) { message.value = error.message; }
|
|
1322
|
+
function finish(text: string) { closeTicket(); message.value = text; }
|
|
1323
|
+
|
|
1324
|
+
async function start(purpose: Mode, post?: Post) {
|
|
1325
|
+
if (busy.value || ticket.value || !session.value?.authenticated) return;
|
|
1326
|
+
busy.value = true; message.value = ''; retryPublish.value = null;
|
|
1327
|
+
const intent = post ? { id: post.id, version: post.version } : null;
|
|
1328
|
+
const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
|
|
1329
|
+
try {
|
|
1330
|
+
if (creation?.signature !== signature) creation = { signature, key: requestKey() };
|
|
1331
|
+
// SDK create() sends only {purpose}. Business fields use your own API.
|
|
1332
|
+
const value = intent
|
|
1333
|
+
? validateTicket(await api('/api/authlink/requests', {
|
|
1334
|
+
purpose, postId: intent.id, version: intent.version,
|
|
1335
|
+
}, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
|
|
1336
|
+
: await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
|
|
1337
|
+
if (lifetime.signal.aborted) return;
|
|
1338
|
+
publication.value = intent; ticket.value = value; creation = null;
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
if (!lifetime.signal.aborted) showError(error as Error);
|
|
1341
|
+
// Retain creation.key for a retry of the SAME purpose/post/version.
|
|
1342
|
+
} finally { busy.value = false; }
|
|
1343
|
+
}
|
|
1344
|
+
async function publish() {
|
|
1345
|
+
const intent = retryPublish.value;
|
|
1346
|
+
if (!intent || busy.value) return;
|
|
1347
|
+
busy.value = true;
|
|
1348
|
+
try {
|
|
1349
|
+
await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
|
|
1350
|
+
retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
|
|
1351
|
+
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
1352
|
+
finally { busy.value = false; }
|
|
1353
|
+
}
|
|
1354
|
+
async function confirmed(result: Confirmation) {
|
|
1355
|
+
const intent = publication.value;
|
|
1356
|
+
closeTicket();
|
|
1357
|
+
if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
|
|
1358
|
+
else message.value = 'Verified. Reload account status from the backend.';
|
|
1359
|
+
}
|
|
1360
|
+
onMounted(async () => {
|
|
1361
|
+
try {
|
|
1362
|
+
const value = await api('/api/session');
|
|
1363
|
+
if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
|
|
1364
|
+
throw new Error('LOGIN_REQUIRED');
|
|
1365
|
+
}
|
|
1366
|
+
session.value = value;
|
|
1367
|
+
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
1368
|
+
});
|
|
1369
|
+
onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
|
|
1370
|
+
</script>
|
|
1371
|
+
|
|
1372
|
+
<template>
|
|
1373
|
+
<button :disabled="busy || !!ticket || !session?.authenticated" @click="start('bind')">Bind phone</button>
|
|
1374
|
+
<button :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm')">Confirm</button>
|
|
1375
|
+
<button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
|
|
1376
|
+
<button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
|
|
1377
|
+
<AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
|
|
1378
|
+
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
1379
|
+
@expired="finish('Expired; start a new request')" @error="showError" />
|
|
1380
|
+
<AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
|
|
1381
|
+
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
1382
|
+
@expired="finish('Expired; start a new request')" @error="showError" />
|
|
1383
|
+
<p role="status">{{ message }}</p>
|
|
1384
|
+
</template>
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
普通绑定/确认使用 `client.create(purpose, {idempotencyKey})`。该方法只发送 purpose,**不能传入 postId/version 扩展请求体**。确认发布通过自己的 fetch 发送这些业务字段,再用 `validateTicket(response, 'confirm', authLinkOrigin)` 校验;后续弹窗查询仍复用官方 client。
|
|
1388
|
+
|
|
1389
|
+
同一次创建失败保留同一个键;用途或文章版本改变才换新键。`shallowRef` 保存 ticket,`markRaw` 保存客户端。组件挂载只打开和观察已有请求;不在 mounted/watch 中创建请求。关闭、拒绝和过期清空 ticket;网络错误保留弹窗,允许重试原请求。确认成功先清空 ticket,再调用独立业务接口;失败保留同一个 requestId / 文章 / 版本用于重试,先从后台确认真实状态后再刷新页面。上例省略网站自己的登录表单、文章列表和错误码中文映射。
|
|
1390
|
+
|
|
1391
|
+
#### 6. 发布接口必须再做后台检查
|
|
1392
|
+
|
|
1393
|
+
`confirmed` 回调只触发界面更新与下一次 API 请求,不是放行凭据。Go 的发布事务必须验证当前 session 对应的已落库回执,purpose=confirm、意图=publish、同一 postId、同一 version、文章归属和草稿状态,以及首次应用时尚未过期。成功时更新文章并标记请求 applied;重试已应用的同一操作返回原成功,不重复发布或写审计。请求不能用于另一文章或新版本,修改内容后须重新确认。前端不把文章内容、action 或 transactionHash 塞入 AuthLink 消费回执。
|
|
1394
|
+
|
|
1395
|
+
#### 验收顺序
|
|
1396
|
+
|
|
1397
|
+
登录 → 读取 session → 查询后台真实账号状态(参考 CMS 通过 overview 调用 Go SDK `AccountStatus`)→ 未绑定时 bind → 手机扫码批准 → 后台消费并刷新账号状态 → 保存草稿 → 按 postId/version confirm → 手机批准 → 结果消费落库 → 独立发布接口 → 重复发布/错误版本/拒绝/过期验证。真实设备验收仍由用户在手机上完成;本地编译和模拟 HTTP 测试不能代替这一步。
|
|
1398
|
+
|
|
869
1399
|
---
|
|
870
1400
|
|
|
871
1401
|
## 日本語
|
|
872
1402
|
|
|
873
1403
|
**AuthLink のスマートフォン登録とアプリでの確認**を Web サイトに組み込む React / Vue コンポーネントです。登録用 QR コード、同期する 6 桁の照合コード、カウントダウン、確認結果を表示します。特定のサイト基盤には依存しません。
|
|
874
1404
|
|
|
1405
|
+
### Go + Vue の接続手順
|
|
1406
|
+
|
|
1407
|
+
Go + Vue の実際の接続手順は [ログイン、端末登録、確認後の公開](#go-vue-cms-ja)、完全なコンポーネントは `examples/vue-cms.vue` を参照してください。
|
|
1408
|
+
|
|
1409
|
+
サイトのログイン → Go `/api/session` の authenticated/csrf → `csrfToken: () => session.csrf` で別 origin のクライアント設定 → bind/confirm 作成 → Go で消費・検証・DB コミット → ダイアログを閉じる → 独立した業務 API で保存済み確認結果を再検証して公開、という順序です。baseUrl は Go、authLinkOrigin は AuthLink を指し、テナントキーは Go に保持します。記事/版付き要求は独自 fetch と validateTicket を使い、SDK create は purpose のみ送ります。
|
|
1410
|
+
|
|
875
1411
|
### 主な機能
|
|
876
1412
|
|
|
877
1413
|
- ブラウザー内で生成するスマートフォン登録用 QR コード。
|
|
@@ -930,12 +1466,12 @@ export function readCsrfTokenFromYourCms(): string {
|
|
|
930
1466
|
export const client = createAuthLinkClient({
|
|
931
1467
|
baseUrl: '/api/authlink',
|
|
932
1468
|
csrfToken: readCsrfTokenFromYourCms,
|
|
933
|
-
//
|
|
934
|
-
authLinkOrigin: 'https://
|
|
1469
|
+
// AuthLink production API origin.
|
|
1470
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
935
1471
|
});
|
|
936
1472
|
```
|
|
937
1473
|
|
|
938
|
-
`https://
|
|
1474
|
+
AuthLink の本番 API は `https://api.getauthlink.com` です。baseUrl は自サイトの業務バックエンドを指し、AuthLink 公式サイトのドメインは API に使いません。独自に配置したサービスへ接続する場合は、その設定済み origin を使用します。リクエストは下記の React 例のようにユーザー操作で作成します。
|
|
939
1475
|
|
|
940
1476
|
### フロントエンドとバックエンドの別オリジン対応(0.0.2 以降)
|
|
941
1477
|
|
|
@@ -945,6 +1481,8 @@ SDK は認証情報を読む前に接続先を検証し、リダイレクトを
|
|
|
945
1481
|
|
|
946
1482
|
#### 別オリジンの Cookie / session
|
|
947
1483
|
|
|
1484
|
+
汎用例の JSON 名は csrfToken ですが、Go CMS の実際の応答は `{ authenticated: true, csrf: '…' }` です。session.csrf を SDK の csrfToken コールバックに渡せばよく、バックエンドのフィールド名を変更する必要はありません。
|
|
1485
|
+
|
|
948
1486
|
下記の `GET /api/session` はサイトで実装する例示 API で、SDK が提供する API ではありません。ログイン session の `{ "csrfToken": "現在のtoken" }` を返し、`Cache-Control: no-store` と CORS を設定します。既存サイトの API パスに適合してください。
|
|
949
1487
|
|
|
950
1488
|
```ts
|
|
@@ -1263,3 +1801,258 @@ React / Vue はダイアログの `className` に対応します。Vue は文字
|
|
|
1263
1801
|
### ライセンス
|
|
1264
1802
|
|
|
1265
1803
|
MIT。ライセンス全文は `LICENSE`、同梱ライブラリの帰属表示は `THIRD_PARTY_NOTICES.md` を参照してください。
|
|
1804
|
+
|
|
1805
|
+
### Go Vue CMS: ja
|
|
1806
|
+
|
|
1807
|
+
実際の Go CMS + Vue 3 の接続順序を示します。例のドメインは置き換え用で、ローカル設定・テスト用パスワード・テナント資格情報は含めません。ログイン、DB、業務 API はサイト側で実装し、Web SDK は表示と監視、Go SDK は AuthLink 呼び出しと応答検証を担当します。
|
|
1808
|
+
|
|
1809
|
+
#### 1. 3 つの origin と SDK
|
|
1810
|
+
|
|
1811
|
+
| 役割 | 例 | 設定 |
|
|
1812
|
+
| --- | --- | --- |
|
|
1813
|
+
| Vue | `https://cms.example.com` | `apiOrigin` は下記 Go バックエンド |
|
|
1814
|
+
| Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
|
|
1815
|
+
| AuthLink API | `https://api.getauthlink.com` | Go SDK の `Origin`;ブラウザーの `authLinkOrigin` |
|
|
1816
|
+
|
|
1817
|
+
AuthLink API は本番の `https://api.getauthlink.com` を使います。Vue と Go CMS の origin は接続先サイト自身の例であり、AuthLink 公式サイトのドメインへ置き換えません。
|
|
1818
|
+
|
|
1819
|
+
テナントに登録するサイト origin は Vue の origin です。Go SDK の Origin と資格情報 JSON の API origin は AuthLink API を指します。ブラウザーの baseUrl は Go の業務 API です。TenantID/APIKey はバックエンドの非公開設定にのみ保存します。
|
|
1820
|
+
|
|
1821
|
+
```sh
|
|
1822
|
+
npm install @auth-link/web@latest vue
|
|
1823
|
+
# In your Go backend module, after unpacking the supplied Go SDK:
|
|
1824
|
+
go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
|
|
1825
|
+
go mod edit -require=authlink.local/sdk@v0.0.0
|
|
1826
|
+
go mod tidy
|
|
1827
|
+
```
|
|
1828
|
+
|
|
1829
|
+
Go SDK はソースまたは公式サイトの Go SDK アーカイブとして取得します。公開 Go module や npm 同梱サーバーではありません。SDK 自身の go.mod を保持し、`authlink "authlink.local/sdk"` を import します。信頼できる設定から `authlink.New(authlink.Config{Origin: authOrigin, TenantID: tenantID, APIKey: apiKey})` で初期化します。
|
|
1830
|
+
|
|
1831
|
+
#### 2. サイトのログインと session
|
|
1832
|
+
|
|
1833
|
+
サイトのログイン API(参照 CMS は POST /api/login)が Secure・HttpOnly の session Cookie を発行します。Vue は credentials=include で GET /api/session を読み、authenticated=true と csrf を確認してから要求を作成します。未ログインならサイト自身のログインへ戻します。
|
|
1834
|
+
|
|
1835
|
+
この CMS の JSON フィールド名は **csrf**、SDK オプション名は **csrfToken** です。`csrfToken: () => session.value?.csrf ?? ''` で対応します。JSON の名前を SDK に合わせる必要はありません。Go が session ごとに生成・保存し、作成と結果照会の両方で X-CSRF-Token を検証します。テナントキーや照合数字を代用しません。
|
|
1836
|
+
|
|
1837
|
+
同じサイトの HTTPS サブドメイン同士は別 origin・同 site です。参照 API の Cookie は Domain を指定しない host-only、SameSite=Strict; Secure; HttpOnly; Path=/ です。別 site の場合は CORS に加えてブラウザーの Cookie 制限も考慮します。
|
|
1838
|
+
|
|
1839
|
+
#### 3. Go の CORS と業務ルート
|
|
1840
|
+
|
|
1841
|
+
```http
|
|
1842
|
+
Access-Control-Allow-Origin: https://cms.example.com
|
|
1843
|
+
Access-Control-Allow-Credentials: true
|
|
1844
|
+
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
|
|
1845
|
+
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
|
|
1846
|
+
Vary: Origin
|
|
1847
|
+
Cache-Control: no-store
|
|
1848
|
+
```
|
|
1849
|
+
|
|
1850
|
+
正確に許可した Vue origin にだけ返し、その origin のエラー応答にも付与します。OPTIONS は origin・method・headers を検証して 204 を返し、ログインや CSRF は要求しません。実リクエストでログイン、CSRF、所有者を検証します。任意 origin の反射や * は使いません。PUT は CMS の下書き編集用なので必要なルートに合わせて限定できます。
|
|
1851
|
+
|
|
1852
|
+
| サイト自身のルート | 入力 | 出力 |
|
|
1853
|
+
| --- | --- | --- |
|
|
1854
|
+
| `GET /api/session` | — | `{authenticated:true, csrf:"…", subject:"…", username:"…"}`;未ログイン: `{authenticated:false}` |
|
|
1855
|
+
| `POST /api/authlink/requests` | `{purpose:"bind"}` または `{purpose:"confirm",postId,version}`; `Idempotency-Key` | 公開 `Ticket` |
|
|
1856
|
+
| `POST /api/authlink/requests/{id}/result` | `{}`;現在の session + CSRF | 待機状態/displayCode または検証済み `confirmed` |
|
|
1857
|
+
| `POST /api/posts/{id}/publish` | `{requestId,version}`; 現在の session + CSRF | `{ok:true}` 保存済み確認結果と業務意図の検証後 |
|
|
1858
|
+
|
|
1859
|
+
これらのルートは Go CMS が実装します。postId/version と公開ルートはこの業務の契約で、AuthLink のプロトコル項目ではありません。
|
|
1860
|
+
|
|
1861
|
+
#### 4. 作成前に保存し、検証のコミット後に confirmed
|
|
1862
|
+
|
|
1863
|
+
```text
|
|
1864
|
+
Vue → Go /api/session → current session + csrf
|
|
1865
|
+
Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
|
|
1866
|
+
Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
|
|
1867
|
+
Go commits verified receipt → Vue confirmed event
|
|
1868
|
+
Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
|
|
1869
|
+
```
|
|
1870
|
+
|
|
1871
|
+
安定したユーザー ID は認証済み session から取得します。用途、作成キー、記事の所有者と版を検証し、state と消費キーを別々に生成します。上流通信の前に CreateInput、ブラウザーの作成キー、ユーザー/session、記事公開の意図を保存します。通信失敗時は同一レコード・入力・キーを再利用します。session + 作成キーを一意にし、異なる用途・記事・版への再利用を拒否します。
|
|
1872
|
+
|
|
1873
|
+
ticket、requestId、元の expiresAt を保存し、任意の qrSvg を除いて公開 ticket を返します。結果 API は現在の session が所有する記録をロックし、保存済み確認結果があれば再利用します。Status が approved/consumed のときだけ、DB の値から Expected を組み立て Consume を実行します。検証済み確認結果、session の確認記録、監査を requestId/verificationId の一意制約付きトランザクションで保存し、コミット後に confirmed を返します。待機中は displayCode を有効期限を越えてキャッシュしません。
|
|
1874
|
+
|
|
1875
|
+
以下は SDK 呼び出し層であり、完全な HTTP サーバーではありません。DB、トランザクション、所有者検証は説明とコメントに従ってサイト側で実装します。
|
|
1876
|
+
|
|
1877
|
+
```go
|
|
1878
|
+
package cmsauth
|
|
1879
|
+
|
|
1880
|
+
import (
|
|
1881
|
+
"context"
|
|
1882
|
+
authlink "authlink.local/sdk"
|
|
1883
|
+
)
|
|
1884
|
+
|
|
1885
|
+
// Load this record from your database for the authenticated user/session.
|
|
1886
|
+
// Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
|
|
1887
|
+
// Intent (post ID/version), ownership and unique constraints belong in your DB.
|
|
1888
|
+
type StoredRequest struct {
|
|
1889
|
+
Input authlink.CreateInput
|
|
1890
|
+
CreateKey string
|
|
1891
|
+
ConsumeKey string
|
|
1892
|
+
Ticket authlink.Ticket
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// On network retries, load the SAME record; do not regenerate State or keys.
|
|
1896
|
+
func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
|
|
1897
|
+
return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
type PublicConfirmation struct {
|
|
1901
|
+
Status string `json:"status"`
|
|
1902
|
+
RequestID string `json:"requestId"`
|
|
1903
|
+
VerificationID string `json:"verificationId"`
|
|
1904
|
+
Purpose string `json:"purpose"`
|
|
1905
|
+
ExpiresAt string `json:"expiresAt"`
|
|
1906
|
+
AuthenticationPolicy string `json:"authenticationPolicy"`
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// Caller checks session/CSRF/ownership, locks the saved record and first returns
|
|
1910
|
+
// any already-saved receipt. For a new confirmed result, caller must persist
|
|
1911
|
+
// the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
|
|
1912
|
+
func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
|
|
1913
|
+
status, err := sdk.Status(ctx, saved.Ticket.RequestID)
|
|
1914
|
+
if err != nil { return nil, err }
|
|
1915
|
+
if status.Status != "approved" && status.Status != "consumed" { return status, nil }
|
|
1916
|
+
result, err := sdk.Consume(ctx, authlink.Expected{
|
|
1917
|
+
RequestID: saved.Ticket.RequestID,
|
|
1918
|
+
ExternalUserID: saved.Input.ExternalUserID,
|
|
1919
|
+
Purpose: saved.Input.Purpose,
|
|
1920
|
+
State: saved.Input.State,
|
|
1921
|
+
ExpiresAt: saved.Ticket.ExpiresAt,
|
|
1922
|
+
}, saved.ConsumeKey)
|
|
1923
|
+
if err != nil { return nil, err }
|
|
1924
|
+
return PublicConfirmation{
|
|
1925
|
+
Status: "confirmed", RequestID: result.RequestID,
|
|
1926
|
+
VerificationID: result.VerificationID, Purpose: result.Purpose,
|
|
1927
|
+
ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
|
|
1928
|
+
}, nil
|
|
1929
|
+
}
|
|
1930
|
+
```
|
|
1931
|
+
|
|
1932
|
+
#### 5. Vue のライフサイクルと業務データ付き要求
|
|
1933
|
+
|
|
1934
|
+
完全なファイルは `examples/vue-cms.vue` にも同梱します。サイトのログイン後、信頼できる apiOrigin/authLinkOrigin と任意の保存済み下書き `post={id,version}` を渡してマウントします。親は認証 session の識別子を key に使います。ログアウト・再ログイン・アカウント切り替えでは先にアンマウントし、古い ticket・作成キー・未完了の公開処理を消去してから新規マウントします。
|
|
1935
|
+
|
|
1936
|
+
```vue
|
|
1937
|
+
<script setup lang="ts">
|
|
1938
|
+
import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
|
1939
|
+
import { createAuthLinkClient, requestKey, validateTicket } from '@auth-link/web';
|
|
1940
|
+
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
1941
|
+
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
1942
|
+
import '@auth-link/web/styles.css';
|
|
1943
|
+
|
|
1944
|
+
type Post = { id: string; version: number };
|
|
1945
|
+
type Publication = Post & { requestId: string };
|
|
1946
|
+
// Supply trusted deployment configuration. Mount after site login and remount
|
|
1947
|
+
// with :key="session.subject" after login, logout or account/session changes.
|
|
1948
|
+
const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
|
|
1949
|
+
const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
|
|
1950
|
+
const ticket = shallowRef<Ticket | null>(null);
|
|
1951
|
+
const publication = shallowRef<Post | null>(null);
|
|
1952
|
+
const retryPublish = shallowRef<Publication | null>(null);
|
|
1953
|
+
const busy = ref(false), message = ref('');
|
|
1954
|
+
let creation: { signature: string; key: string } | null = null;
|
|
1955
|
+
const lifetime = new AbortController();
|
|
1956
|
+
const client = markRaw(createAuthLinkClient({
|
|
1957
|
+
baseUrl: `${props.apiOrigin}/api/authlink`,
|
|
1958
|
+
allowedBackendOrigins: [props.apiOrigin],
|
|
1959
|
+
credentials: 'include',
|
|
1960
|
+
csrfToken: () => session.value?.csrf ?? '',
|
|
1961
|
+
authLinkOrigin: props.authLinkOrigin,
|
|
1962
|
+
}));
|
|
1963
|
+
|
|
1964
|
+
async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
|
|
1965
|
+
const response = await fetch(`${props.apiOrigin}${path}`, {
|
|
1966
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
1967
|
+
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
1968
|
+
signal: lifetime.signal,
|
|
1969
|
+
headers: body === undefined ? {} : {
|
|
1970
|
+
'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
|
|
1971
|
+
},
|
|
1972
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1973
|
+
});
|
|
1974
|
+
const value = await response.json();
|
|
1975
|
+
if (response.status === 401) {
|
|
1976
|
+
session.value = null; closeTicket(); retryPublish.value = null; creation = null;
|
|
1977
|
+
}
|
|
1978
|
+
if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
|
|
1979
|
+
return value;
|
|
1980
|
+
}
|
|
1981
|
+
function closeTicket() { ticket.value = null; publication.value = null; }
|
|
1982
|
+
function showError(error: Error) { message.value = error.message; }
|
|
1983
|
+
function finish(text: string) { closeTicket(); message.value = text; }
|
|
1984
|
+
|
|
1985
|
+
async function start(purpose: Mode, post?: Post) {
|
|
1986
|
+
if (busy.value || ticket.value || !session.value?.authenticated) return;
|
|
1987
|
+
busy.value = true; message.value = ''; retryPublish.value = null;
|
|
1988
|
+
const intent = post ? { id: post.id, version: post.version } : null;
|
|
1989
|
+
const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
|
|
1990
|
+
try {
|
|
1991
|
+
if (creation?.signature !== signature) creation = { signature, key: requestKey() };
|
|
1992
|
+
// SDK create() sends only {purpose}. Business fields use your own API.
|
|
1993
|
+
const value = intent
|
|
1994
|
+
? validateTicket(await api('/api/authlink/requests', {
|
|
1995
|
+
purpose, postId: intent.id, version: intent.version,
|
|
1996
|
+
}, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
|
|
1997
|
+
: await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
|
|
1998
|
+
if (lifetime.signal.aborted) return;
|
|
1999
|
+
publication.value = intent; ticket.value = value; creation = null;
|
|
2000
|
+
} catch (error) {
|
|
2001
|
+
if (!lifetime.signal.aborted) showError(error as Error);
|
|
2002
|
+
// Retain creation.key for a retry of the SAME purpose/post/version.
|
|
2003
|
+
} finally { busy.value = false; }
|
|
2004
|
+
}
|
|
2005
|
+
async function publish() {
|
|
2006
|
+
const intent = retryPublish.value;
|
|
2007
|
+
if (!intent || busy.value) return;
|
|
2008
|
+
busy.value = true;
|
|
2009
|
+
try {
|
|
2010
|
+
await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
|
|
2011
|
+
retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
|
|
2012
|
+
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
2013
|
+
finally { busy.value = false; }
|
|
2014
|
+
}
|
|
2015
|
+
async function confirmed(result: Confirmation) {
|
|
2016
|
+
const intent = publication.value;
|
|
2017
|
+
closeTicket();
|
|
2018
|
+
if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
|
|
2019
|
+
else message.value = 'Verified. Reload account status from the backend.';
|
|
2020
|
+
}
|
|
2021
|
+
onMounted(async () => {
|
|
2022
|
+
try {
|
|
2023
|
+
const value = await api('/api/session');
|
|
2024
|
+
if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
|
|
2025
|
+
throw new Error('LOGIN_REQUIRED');
|
|
2026
|
+
}
|
|
2027
|
+
session.value = value;
|
|
2028
|
+
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
2029
|
+
});
|
|
2030
|
+
onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
|
|
2031
|
+
</script>
|
|
2032
|
+
|
|
2033
|
+
<template>
|
|
2034
|
+
<button :disabled="busy || !!ticket || !session?.authenticated" @click="start('bind')">Bind phone</button>
|
|
2035
|
+
<button :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm')">Confirm</button>
|
|
2036
|
+
<button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
|
|
2037
|
+
<button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
|
|
2038
|
+
<AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
|
|
2039
|
+
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
2040
|
+
@expired="finish('Expired; start a new request')" @error="showError" />
|
|
2041
|
+
<AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
|
|
2042
|
+
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
2043
|
+
@expired="finish('Expired; start a new request')" @error="showError" />
|
|
2044
|
+
<p role="status">{{ message }}</p>
|
|
2045
|
+
</template>
|
|
2046
|
+
```
|
|
2047
|
+
|
|
2048
|
+
client.create が送るのは purpose のみです。postId/version が必要なら業務 API に fetch で送り、`validateTicket(response, 'confirm', authLinkOrigin)` で検証した後、監視には公式 client を使います。同じ作成の失敗ではキーを保持し、用途・記事・版が変わったときだけ新規キーにします。ticket は shallowRef、client は markRaw で保持します。
|
|
2049
|
+
|
|
2050
|
+
マウント時は既存要求を監視し、mounted/watch で新規作成しません。閉じる・拒否・期限切れでは ticket を消去し、通信エラーでは同じ要求を再試行できるよう表示を維持します。confirmed 後は表示を閉じ、独立した公開 API を呼びます。失敗時は同じ requestId/記事/版で再試行し、実状態をバックエンドから再取得します。サイト自身のログインフォーム・記事一覧・エラー翻訳は例から省略しています。
|
|
2051
|
+
|
|
2052
|
+
#### 6. 公開トランザクションで再検証
|
|
2053
|
+
|
|
2054
|
+
ブラウザーの confirmed イベントは許可証ではありません。Go は現在の session の保存済み確認結果を読み、purpose=confirm、intent=publish、記事の所有者、同一 postId/version、下書き状態、初回適用時の有効期限を検証します。記事更新と applied の記録を一括コミットします。同じ適用済み操作の再試行は重複更新・監査なしで成功を返します。別記事や編集後の版には新たな確認が必要です。AuthLink 確認結果に action、transactionHash、記事本文を追加しません。
|
|
2055
|
+
|
|
2056
|
+
#### 検証順序
|
|
2057
|
+
|
|
2058
|
+
サイトログイン → session → バックエンド AccountStatus(参照 CMS は overview 経由)→ 未登録なら bind → スマートフォンで承認 → 消費と状態再取得 → 下書き保存 → 記事/版の confirm → 承認 → 確認結果保存 → 公開 → 再試行・異なる版・拒否・期限切れ。実端末での承認は利用者が行い、コンパイルや模擬 HTTP 検証とは区別します。
|