add webauthn (passkey) support

This commit is contained in:
Christoph Haas
2025-05-12 22:53:43 +02:00
parent 6a96925be7
commit 1394be2341
28 changed files with 1603 additions and 33 deletions

View File

@@ -3,13 +3,17 @@ import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
import router from '../router'
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
import {base64_url_encode} from "@/helpers/encoding";
export const authStore = defineStore('auth',{
state: () => ({
// initialize state from local storage to enable user to stay logged in
user: JSON.parse(localStorage.getItem('user')),
providers: [],
returnUrl: localStorage.getItem('returnUrl')
returnUrl: localStorage.getItem('returnUrl'),
webAuthnCredentials: [],
fetching: false,
}),
getters: {
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
@@ -18,6 +22,14 @@ export const authStore = defineStore('auth',{
IsAuthenticated: (state) => state.user != null,
IsAdmin: (state) => state.user?.IsAdmin || false,
ReturnUrl: (state) => state.returnUrl || '/',
IsWebAuthnEnabled: (state) => {
if (state.webAuthnCredentials) {
return state.webAuthnCredentials.length > 0
}
return false
},
WebAuthnCredentials: (state) => state.webAuthnCredentials || [],
isFetching: (state) => state.fetching,
},
actions: {
SetReturnUrl(link) {
@@ -60,6 +72,23 @@ export const authStore = defineStore('auth',{
return Promise.reject(err)
})
},
// LoadWebAuthnCredentials returns promise that might have been rejected if the session was not authenticated.
async LoadWebAuthnCredentials() {
this.fetching = true
return apiWrapper.get(`/auth/webauthn/credentials`)
.then(credentials => {
this.setWebAuthnCredentials(credentials)
})
.catch(error => {
this.setWebAuthnCredentials([])
console.log("Failed to load webauthn credentials:", error)
notify({
title: "Backend Connection Failure",
text: error,
type: 'error',
})
})
},
// Login returns promise that might have been rejected if the login attempt was not successful.
async Login(username, password) {
return apiWrapper.post(`/auth/login`, { username, password })
@@ -93,6 +122,157 @@ export const authStore = defineStore('auth',{
await router.push('/login')
},
async RegisterWebAuthn() {
// check if the browser supports WebAuthn
if (!browserSupportsWebAuthn()) {
console.error("WebAuthn is not supported by this browser.");
notify({
title: "WebAuthn not supported",
text: "This browser does not support WebAuthn.",
type: 'error'
});
return Promise.reject(new Error("WebAuthn not supported"));
}
this.fetching = true
console.log("Starting WebAuthn registration...")
await apiWrapper.post(`/auth/webauthn/register/start`, {})
.then(optionsJSON => {
notify({
title: "Passkey registration",
text: "Starting passkey registration, follow the instructions in the browser."
});
console.log("Started WebAuthn registration with options: ", optionsJSON)
return startRegistration({ optionsJSON: optionsJSON.publicKey }).then(attResp => {
console.log("Finishing WebAuthn registration...")
return apiWrapper.post(`/auth/webauthn/register/finish`, attResp)
.then(credentials => {
console.log("Passkey registration finished successfully: ", credentials)
this.setWebAuthnCredentials(credentials)
notify({
title: "Passkey registration",
text: "A new passkey has been registered successfully!",
type: 'success'
});
})
.catch(err => {
this.fetching = false
console.error("Failed to register passkey:", err);
notify({
title: "Passkey registration failed",
text: err,
type: 'error'
});
})
}).catch(err => {
this.fetching = false
console.error("Failed to start WebAuthn registration:", err);
notify({
title: "Failed to start Passkey registration",
text: err,
type: 'error'
});
})
})
.catch(err => {
this.fetching = false
console.error("Failed to start WebAuthn registration:", err);
notify({
title: "Failed to start WebAuthn registration",
text: err,
type: 'error'
});
})
},
async DeleteWebAuthnCredential(credentialId) {
this.fetching = true
return apiWrapper.delete(`/auth/webauthn/credential/${base64_url_encode(credentialId)}`)
.then(credentials => {
this.setWebAuthnCredentials(credentials)
notify({
title: "Success",
text: "Passkey deleted successfully!",
type: 'success',
})
})
.catch(err => {
this.fetching = false
console.error("Failed to delete webauthn credential:", err);
notify({
title: "Backend Connection Failure",
text: err,
type: 'error',
})
})
},
async RenameWebAuthnCredential(credential) {
this.fetching = true
return apiWrapper.put(`/auth/webauthn/credential/${base64_url_encode(credential.ID)}`, {
Name: credential.Name,
})
.then(credentials => {
this.setWebAuthnCredentials(credentials)
notify({
title: "Success",
text: "Passkey renamed successfully!",
type: 'success',
})
})
.catch(err => {
this.fetching = false
console.error("Failed to rename webauthn credential", credential.ID, ":", err);
notify({
title: "Backend Connection Failure",
text: err,
type: 'error',
})
})
},
async LoginWebAuthn() {
// check if the browser supports WebAuthn
if (!browserSupportsWebAuthn()) {
console.error("WebAuthn is not supported by this browser.");
notify({
title: "WebAuthn not supported",
text: "This browser does not support WebAuthn.",
type: 'error'
});
return Promise.reject(new Error("WebAuthn not supported"));
}
this.fetching = true
console.log("Starting WebAuthn login...")
await apiWrapper.post(`/auth/webauthn/login/start`, {})
.then(optionsJSON => {
console.log("Started WebAuthn login with options: ", optionsJSON)
return startAuthentication({ optionsJSON: optionsJSON.publicKey }).then(asseResp => {
console.log("Finishing WebAuthn login ...")
return apiWrapper.post(`/auth/webauthn/login/finish`, asseResp)
.then(user => {
console.log("Passkey login finished successfully for user:", user.Identifier)
this.ResetReturnUrl()
this.setUserInfo(user)
return user.Identifier
})
.catch(err => {
console.error("Failed to login with passkey:", err)
this.setUserInfo(null)
return Promise.reject(new Error("login failed"))
})
}).catch(err => {
console.error("Failed to finish passkey login:", err)
this.setUserInfo(null)
return Promise.reject(new Error("login failed"))
})
})
.catch(err => {
console.error("Failed to start passkey login:", err)
this.setUserInfo(null)
return Promise.reject(new Error("login failed"))
})
},
// -- internal setters
setUserInfo(userInfo) {
// store user details and jwt in local storage to keep user logged in between page refreshes
@@ -120,5 +300,9 @@ export const authStore = defineStore('auth',{
localStorage.removeItem('user')
}
},
setWebAuthnCredentials(credentials) {
this.fetching = false
this.webAuthnCredentials = credentials
}
}
});

View File

@@ -129,7 +129,7 @@ export const profileStore = defineStore('profile', {
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/enable`)
.then(this.setUser)
.catch(error => {
this.setPeers([])
this.fetching = false
console.log("Failed to activate API for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",
@@ -143,7 +143,7 @@ export const profileStore = defineStore('profile', {
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/disable`)
.then(this.setUser)
.catch(error => {
this.setPeers([])
this.fetching = false
console.log("Failed to deactivate API for ", currentUser, ": ", error)
notify({
title: "Backend Connection Failure",