Base de données persistante en localStorage
Lors de l'étape précédente, nous avions ajouté une étape de pré-dérivation du mot de passe sur la méthode d'authentification pour assurer la sécurité de la protection par mot de passe de l'identité Seald d'un utilisateur.
Nous sommes toujours confrontés au problème que quand un utilisateur ouvre un nouvel onglet de l'application, bien qu'ayant une session authentifiée, l'utilisateur doit retaper son mot de passe pour récupérer son identité Seald.
Lors de cette étape, nous ajouterons une mise en cache de l'identité en localStorage pour que l'utilisateur puisse ouvrir un nouvel onglet.
La branche sur laquelle est basée cette étape est 2-pre-derivation
, le résultat final est 3-localstorage
.
Explication
Dans le mécanisme implémenté dans les étapes précédentes, l'identité Seald d'un utilisateur est récupérée depuis SSKS puis déchiffrée à l'aide du mot de passe de l'utilisateur. Ainsi, cette identité n'est conservée qu'en mémoire côté client.
Il est pourtant souhaitable que quand un utilisateur actualise la page, ou ouvre un nouvel onglet, il ait accès à son identité Seald sans retaper de mot de passe.
La solution que nous préconisons est de mettre la base de donnée en cache dans le navigateur dans le localStorage, et par mesure de précaution, nous préconisons de protéger cette base de données en localStorage par une clé stockée en backend appelée databaseKey
.
TIP
La base de donnée de Seald utilise nedb
, qui dans le navigateur, n'utilise pas forcément le localStorage, mais choisit (via localForage
) la meilleure méthode de stockage entre IndexedDB, WebSQL ou localStorage selon votre navigateur.
Lorsque l'utilisateur va rouvrir l'application, la databaseKey
sera récupérée depuis le backend via une session authentifiée, et cette clé sera utilisée pour déchiffrer l'encryptedDB
.
Tout cela se fait sans aucune interaction utilisateur.
Stockage de la base de données
Pour stocker la base de données, nous allons modifier la fonction instantiateSealdSDK
pour sauvegarder la base de données en localStorage de façon chiffrée.
Pour cela, on ajoute un argument databasePath
pour enregistrer la base de données, et un argument databaseKey
pour la chiffrer.
TIP
Cet exemple fait le choix d'utiliser une databaseKey
différente par session. Pour une implémentation plus simple, vous pouvez plutôt choisir de n'utiliser qu'une seule et même databaseKey
par utilisateur pour toutes les sessions.
const instantiateSealdSDK = async ({ databaseKey, sessionID }: { databaseKey: string, sessionID: string }): Promise<void> => {
sealdSDKInstance = SealdSDKConstructor({
appId: await getSetting('APP_ID'),
apiURL: await getSetting('API_URL'), // Optional. If not set, defaults to public apiURL https://api.seald.io
databaseKey,
databasePath: `seald-example-project-session-${sessionID}`,
plugins: [
SealdSDKPluginSSKSPassword(await getSetting('KEY_STORAGE_URL')) // Optional. If not set, defaults to public keyStorageURL https://ssks.seald.io
]
})
await sealdSDKInstance.initialize()
}
TIP
On rajoute sessionID
dans le databasePath
afin que celui-ci soit unique pour chaque session.
En effet, si un même utilisateur se déconnecte, puis se reconnecte, le serveur aura généré une nouvelle databaseKey
. On ne peut donc pas utiliser la même base de données : il faut que la base de données soit unique pour chaque session, on fait donc varier le databasePath
en fonction du sessionID
.
Lorsque l'on appelle la fonction createIdentity
, l'identité est à la fois sauvegardée sur SSKS par le mot de passe connu de l'utilisateur, et en localStorage protégée avec la databaseKey
.
De même, quand on appelle retrieveIdentity
, l'identitée récupérée sur SSKS est stockée en localStorage protégée avec la databaseKey
.
Nous verrons par la suite comment gérer cette databaseKey
.
TIP
Il est possible de créer une sous-identité différente pour la protection par mot de passe et pour la base de données locale.
Ici, par souci de simplicité, la même identité sera dans la base de donnée locale et protégée par mot de passe.
Récupération de l'identité
Pour récupérer l'identité depuis le localStorage, on va créer une fonction retrieveIdentityFromLocalStorage
qui instancie le SDK et vérifie que la base de données est dans l'état attendu :
export const retrieveIdentityFromLocalStorage = async ({ databaseKey, sessionID }: { databaseKey: string, sessionID: string }): Promise<string> => {
await instantiateSealdSDK({ databaseKey, sessionID })
await sealdSDKInstance.initialize()
const status = await sealdSDKInstance.registrationStatus()
if (status !== 'registered') {
throw new Error('Not registered')
}
const accountInfo = await sealdSDKInstance.getCurrentAccountInfo()
return accountInfo.sealdId
}
Lorsque que l'on appelle la fonction retrieveIdentityFromLocalStorage
, la base de données est récupérée depuis le localStorage, déchiffrée avec la databaseKey
et chargée dans le SDK. Nous verrons par la suite comment gérer cette databaseKey
.
Gestion de databaseKey
La databaseKey
est un secret qui protège la base de donnée enregistrée en localStorage, et donc l'identité de l'utilisateur. Elle doit être récupérable par l'utilisateur de façon authentifiée.
Nous allons la générer depuis le backend, la stocker en session, et modifier le client API du frontend en conséquence.
TIP
Pour une instanciation plus rapide, vous pouvez utiliser une databaseRawKey
à la place de la databaseKey
. Pour plus de détails, allez voir le paragraphe "Utilisation d'une base de données persistante" du guide sur les identités.
Modification de l'API en backend
Pour cela, nous allons modifier les routes suivantes dans le fichier backend/routes/account.js
:
- de création de compte :
POST /account/
pour générer, stocker et rendre ladatabaseKey
et lesessionID
; - de connexion :
POST /account/login
pour générer, stocker et rendre ladatabaseKey
et lesessionID
; - de déconnexion :
GET /account/logout
pour supprimer ladatabaseKey
; - de statut :
GET /account/
pour rendre ladatabaseKey
et lesessionID
.
Avant de modifier les routes, il faut importer le module crypto
pour générer des databaseKey
aléatoires avec fonction :
import { randomBytes as _randomBytes } from 'crypto'
import { promisify } from 'util'
const randomBytes = promisify(_randomBytes)
Dans les routes de création de compte et de connexion, on génère et rend la databaseKey
:
/* ... */
req.session.databaseKey = (await randomBytes(64)).toString('base64')
res.json({
user: user.serialize(),
databaseKey: req.session.databaseKey,
sessionID: req.sessionID
})
Dans la route de statut, on ne fait que rendre la databaseKey
:
/* ... */
res.json({
user: user.serialize(),
databaseKey: req.session.databaseKey,
sessionID: req.sessionID
})
Dans la route de déconnexion, on supprime la databaseKey
de la session :
/* ... */
delete req.session.databaseKey
Modification du client API en frontend
Nous allons répercuter ces modifications dans le client d'API frontend/src/services/api.ts
.
Pour cela il faut :
- stocker
databaseKey
etsessionID
en propriétés de la classeUser
- les récupérer après les appels API dans les méthodes statiques
User.createAccount
,User.login
etUser.updateCurrentUser
.
class User {
readonly id: string
readonly name: string
readonly emailAddress: string
sealdId?: string
readonly signupJWT?: string
readonly databaseKey?: string
readonly sessionID?: string
constructor ({ id, name, emailAddress, sealdId, signupJWT, databaseKey, sessionID }: { id: string, name: string, emailAddress: string, sealdId?: string, signupJWT?: string, sessionID?: string, databaseKey?: string }) {
this.id = id
this.name = name
this.emailAddress = emailAddress
if (sealdId != null) this.sealdId = sealdId
if (signupJWT != null) this.signupJWT = signupJWT
if (databaseKey != null) this.databaseKey = databaseKey // only for currentUser
if (sessionID != null) this.sessionID = sessionID // only for currentUser
}
/* ... */
static async createAccount ({ emailAddress, password, name }: CreateAccountType): Promise<User> {
const preDerivedPassword = await preDerivePassword(password, emailAddress)
const { user: { id }, databaseKey, sessionID, signupJWT } = await apiClient.rest.account.create({
emailAddress,
password: preDerivedPassword,
name
})
currentUser = new this({
id,
emailAddress,
name,
signupJWT,
databaseKey,
sessionID
})
return currentUser
}
static async login ({ emailAddress, password }: LoginType): Promise<User> {
const preDerivedPassword = await preDerivePassword(password, emailAddress)
const { user: { id, name, sealdId }, databaseKey, sessionID } = await apiClient.rest.account.login({
emailAddress,
password: preDerivedPassword
})
currentUser = new this({
id,
emailAddress,
name,
sealdId,
databaseKey,
sessionID
})
return currentUser
}
static async updateCurrentUser (): Promise<User> {
const { user: { id, emailAddress, name, sealdId }, databaseKey, sessionID } = await apiClient.rest.account.status()
currentUser = new this({
id,
emailAddress,
name,
sealdId,
databaseKey,
sessionID
})
return currentUser
}
/* ... */
}
Récupération depuis le localStorage à l'initialisation
La dernière étape est d'appeler retrieveIdentityFromLocalStorage
au chargement de l'application dans la fonction init
de frontend/App.tsx
.
Il faut :
- tenter un appel
GET /account/
pour vérifier si le navigateur dispose d'une session authentifiée, et récupérerdatabaseKey
etsessionID
; - tenter de récupérer et déchiffrer la base de données stockée en localStorage.
En cas d'erreur sur l'une de ces deux étapes, on définit le currentUser
à null
ce qui va emmener l'utilisateur sur l'étape de connexion.
const init = async (): Promise<void> => {
try {
let currentUser
// We need to retrieve :
// 1/ the profile of the user (userId, name, etc.) & databaseKey
// 2/ its Seald identity from the localStorage
// If any of these steps fail, we need to log in again.
try {
currentUser = await User.updateCurrentUser() // Retrieve the profile & databaseKey
if (currentUser.id === '' || currentUser.databaseKey == null || currentUser.sessionID == null) { // Check we got at least the id, databaseKey & sessionID
await (User.logout().catch(() => {})) // if not, let's log out gracefully
throw new Error('Retrieved profile incomplete') // and skip to catch
}
const sealdId = await retrieveIdentityFromLocalStorage({ // We try to retrieve the Seald identity from localStorage
databaseKey: currentUser.databaseKey,
sessionID: currentUser.sessionID
})
currentUser.sealdId = sealdId
} catch (error) {
console.error(error)
currentUser = null
}
/* ... */
} catch (error) { /* ... */ }
}
Conclusion
L'identité est désormais mise en cache, un utilisateur connecté peut donc fermer la fenêtre de chat et la rouvrir sans devoir retaper son mot de passe.
On a désormais une version fonctionnelle et robuste d'un chat chiffré de bout-en-bout utilisant le SDK Seald et la protection par mot de passe.
Néanmoins, si l'utilisateur oublie son mot de passe, il ne pourra plus déchiffrer ses données.
Une solution à ce problème est de remplacer la protection de l'identité par mot de passe par une protection de l'identité en 2-man-rule.