7
0
forked from yvv/decision
Files
Yvv 224e5b0f5e
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Auth Duniter v2 : vérification réelle + extension signing + titres outils
Backend :
- Vérification Sr25519/Ed25519 réelle via substrateinterface (bypass démo)
- Message signé : <Bytes>{challenge}</Bytes> (convention polkadot.js)
- DEV_PROFILES : Charlie → Référent structure, Dave → Auteur (WoT member)

Frontend :
- Signing via extension polkadot.js / Cesium2 (_signWithExtension)
- @polkadot/extension-dapp + @polkadot/util installés
- Vite : global=globalThis + optimizeDeps pour les packages polkadot
- Boîte à outils : titres complets des 4 sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 03:36:51 +01:00

212 lines
5.7 KiB
TypeScript

/**
* Auth store: manages Duniter Ed25519 challenge-response authentication.
*
* Persists the session token in localStorage for SPA rehydration.
* The identity object mirrors the backend IdentityOut schema.
*/
/**
* Sign a challenge using the injected Duniter/Substrate wallet extension
* (Cesium2, polkadot.js extension, Talisman, etc.).
*
* The extension signs <Bytes>{challenge}</Bytes> to match the backend verifier.
*/
async function _signWithExtension(address: string, challenge: string): Promise<string> {
const { web3Enable, web3FromAddress } = await import('@polkadot/extension-dapp')
const { stringToHex } = await import('@polkadot/util')
const extensions = await web3Enable('libreDecision')
if (!extensions.length) {
throw new Error('Aucune extension Duniter détectée. Installez Cesium² ou Polkadot.js.')
}
let injector
try {
injector = await web3FromAddress(address)
} catch {
throw new Error(`Adresse ${address.slice(0, 10)}… introuvable dans l'extension.`)
}
if (!injector.signer?.signRaw) {
throw new Error("L'extension ne supporte pas la signature de messages bruts.")
}
const { signature } = await injector.signer.signRaw({
address,
data: stringToHex(challenge),
type: 'bytes',
})
return signature
}
export interface DuniterIdentity {
id: string
address: string
display_name: string | null
wot_status: string
is_smith: boolean
is_techcomm: boolean
}
interface AuthState {
token: string | null
identity: DuniterIdentity | null
loading: boolean
error: string | null
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
token: null,
identity: null,
loading: false,
error: null,
}),
getters: {
isAuthenticated: (state): boolean => !!state.token && !!state.identity,
isSmith: (state): boolean => state.identity?.is_smith ?? false,
isTechComm: (state): boolean => state.identity?.is_techcomm ?? false,
displayName: (state): string => {
if (!state.identity) return ''
return state.identity.display_name || state.identity.address.slice(0, 12) + '...'
},
},
actions: {
/**
* Initiate the challenge-response login flow.
*
* Steps:
* 1. POST /auth/challenge with the Duniter SS58 address
* 2. Client signs the challenge with Ed25519 private key
* 3. POST /auth/verify with address + signature + challenge
* 4. Store the returned token and identity
*/
async login(address: string, signFn?: (challenge: string) => Promise<string>) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
// Step 1: Request challenge
const challengeRes = await $api<{ challenge: string; expires_at: string }>(
'/auth/challenge',
{
method: 'POST',
body: { address },
},
)
// Step 2: Sign the challenge via polkadot.js / Cesium2 extension
let signature: string
if (signFn) {
signature = await signFn(challengeRes.challenge)
} else {
signature = await _signWithExtension(address, challengeRes.challenge)
}
// Step 3: Verify and get token
const verifyRes = await $api<{ token: string; identity: DuniterIdentity }>(
'/auth/verify',
{
method: 'POST',
body: {
address,
signature,
challenge: challengeRes.challenge,
},
},
)
// Step 4: Store credentials
this.token = verifyRes.token
this.identity = verifyRes.identity
this._persistToken()
return verifyRes
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur de connexion'
throw err
} finally {
this.loading = false
}
},
/**
* Fetch the currently authenticated identity from the backend.
* Used on app init to validate a persisted token.
*/
async fetchMe() {
if (!this.token) return
this.loading = true
this.error = null
try {
const { $api } = useApi()
const identity = await $api<DuniterIdentity>('/auth/me')
this.identity = identity
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Session invalide'
this.token = null
this.identity = null
this._clearToken()
throw err
} finally {
this.loading = false
}
},
/**
* Log out: invalidate session on server and clear local state.
*/
async logout() {
try {
if (this.token) {
const { $api } = useApi()
await $api('/auth/logout', { method: 'POST' })
}
} catch {
// Ignore errors during logout -- clear local state regardless
} finally {
this.token = null
this.identity = null
this.error = null
this._clearToken()
navigateTo('/login')
}
},
/**
* Hydrate the token from localStorage on app init.
*/
hydrateFromStorage() {
if (import.meta.client) {
const stored = localStorage.getItem('libredecision_token')
if (stored) {
this.token = stored
}
}
},
/** @internal Persist token to localStorage */
_persistToken() {
if (import.meta.client && this.token) {
localStorage.setItem('libredecision_token', this.token)
}
},
/** @internal Clear token from localStorage */
_clearToken() {
if (import.meta.client) {
localStorage.removeItem('libredecision_token')
}
},
},
})
// Note: hydration from localStorage happens in app.vue onMounted
// via auth.hydrateFromStorage() before calling auth.fetchMe().