diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go
index 456290c..dbd2020 100644
--- a/cmd/wg-portal/main.go
+++ b/cmd/wg-portal/main.go
@@ -88,6 +88,9 @@ func main() {
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
internal.AssertNoError(err)
+ webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
+ internal.AssertNoError(err)
+
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
internal.AssertNoError(err)
wireGuardManager.StartBackgroundJobs(ctx)
@@ -124,7 +127,8 @@ func main() {
apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager)
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
- apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator)
+ apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator,
+ webAuthn)
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
diff --git a/docs/documentation/configuration/examples.md b/docs/documentation/configuration/examples.md
index 1409d2b..41a4034 100644
--- a/docs/documentation/configuration/examples.md
+++ b/docs/documentation/configuration/examples.md
@@ -32,6 +32,10 @@ database:
type: sqlite
dsn: data/sqlite.db
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
+
+auth:
+ webauthn:
+ enabled: true
```
## LDAP Authentication and Synchronization
diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md
index bc47ba0..3cbdb59 100644
--- a/docs/documentation/configuration/overview.md
+++ b/docs/documentation/configuration/overview.md
@@ -72,6 +72,8 @@ auth:
oidc: []
oauth: []
ldap: []
+ webauthn:
+ enabled: true
web:
listening_address: :8888
@@ -120,6 +122,7 @@ More advanced options are found in the subsequent `Advanced` section.
### `admin_password`
- **Default:** `wgportal`
- **Description:** The administrator password. The default password of `wgportal` should be changed immediately.
+- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters.
### `admin_api_token`
- **Default:** *(empty)*
@@ -334,7 +337,7 @@ Options for configuring email notifications or sending peer configurations via e
## Auth
-WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), and **LDAP** (`ldap`).
+WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), **Passkeys** (`webauthn`) and **LDAP** (`ldap`).
Each can have multiple providers configured. Below are the relevant keys.
---
@@ -580,6 +583,16 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
---
+### WebAuthn (Passkeys)
+
+The `webauthn` section contains configuration options for WebAuthn authentication (passkeys).
+
+#### `enabled`
+- **Default:** `true`
+- **Description:** If `true`, Passkey authentication is enabled. If `false`, WebAuthn is disabled.
+ Users are encouraged to use Passkeys for secure authentication instead of passwords.
+ If a passkey is registered, the password login is still available as a fallback. Ensure that the password is strong and secure.
+
## Web
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
diff --git a/frontend/index.html b/frontend/index.html
index b667f8d..fd8033f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -24,7 +24,7 @@
-
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f12f2f7..afbb37b 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,6 +12,7 @@
"@fortawesome/fontawesome-free": "^6.7.2",
"@kyvg/vue3-notification": "^3.4.1",
"@popperjs/core": "^2.11.8",
+ "@simplewebauthn/browser": "^13.1.0",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.5",
"bootswatch": "^5.3.5",
@@ -863,6 +864,12 @@
"win32"
]
},
+ "node_modules/@simplewebauthn/browser": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz",
+ "integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index baf850f..0be893a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,7 @@
"@fortawesome/fontawesome-free": "^6.7.2",
"@kyvg/vue3-notification": "^3.4.1",
"@popperjs/core": "^2.11.8",
+ "@simplewebauthn/browser": "^13.1.0",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.5",
"bootswatch": "^5.3.5",
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 53a4b7c..cffbb8a 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -140,6 +140,7 @@ const currentYear = ref(new Date().getFullYear())
-
+
+
diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json
index 32a4251..3a172d6 100644
--- a/frontend/src/lang/translations/de.json
+++ b/frontend/src/lang/translations/de.json
@@ -29,7 +29,8 @@
"label": "Passwort",
"placeholder": "Bitte geben Sie Ihr Passwort ein"
},
- "button": "Anmelden"
+ "button": "Anmelden",
+ "button-webauthn": "Passkey verwenden"
},
"menu": {
"home": "Home",
@@ -188,6 +189,35 @@
"button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.",
"button-enable-text": "API aktivieren",
"api-link": "API Dokumentation"
+ },
+ "webauthn": {
+ "headline": "Passkey-Einstellungen",
+ "abstract": "Passkeys sind eine moderne Möglichkeit, Benutzer ohne Passwort zu authentifizieren. Sie werden sicher in Ihrem Browser gespeichert und können verwendet werden, um sich im WireGuard-Portal anzumelden.",
+ "active-description": "Mindestens ein Passkey ist derzeit für Ihr Benutzerkonto aktiv.",
+ "inactive-description": "Für Ihr Benutzerkonto sind derzeit keine Passkeys registriert. Drücken Sie die Schaltfläche unten, um einen neuen Passkey zu registrieren.",
+ "table": {
+ "name": "Name",
+ "created": "Erstellt",
+ "actions": ""
+ },
+ "credentials-list": "Derzeit registrierte Passkeys",
+ "modal-delete": {
+ "headline": "Passkey löschen",
+ "abstract": "Sind Sie sicher, dass Sie diesen Passkey löschen möchten? Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
+ "created": "Erstellt:",
+ "button-delete": "Löschen",
+ "button-cancel": "Abbrechen"
+ },
+ "button-rename-title": "Umbenennen",
+ "button-rename-text": "Passkey umbenennen.",
+ "button-save-title": "Speichern",
+ "button-save-text": "Neuen Namen des Passkeys speichern.",
+ "button-cancel-title": "Abbrechen",
+ "button-cancel-text": "Umbenennung des Passkeys abbrechen.",
+ "button-delete-title": "Löschen",
+ "button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
+ "button-register-title": "Passkey registrieren",
+ "button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern."
}
},
"audit": {
diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json
index 49ef9a8..4ee8199 100644
--- a/frontend/src/lang/translations/en.json
+++ b/frontend/src/lang/translations/en.json
@@ -29,7 +29,8 @@
"label": "Password",
"placeholder": "Please enter your password"
},
- "button": "Sign in"
+ "button": "Sign in",
+ "button-webauthn": "Use Passkey"
},
"menu": {
"home": "Home",
@@ -188,6 +189,35 @@
"button-enable-title": "Enable API, this will generate a new token.",
"button-enable-text": "Enable API",
"api-link": "API Documentation"
+ },
+ "webauthn": {
+ "headline": "Passkey Settings",
+ "abstract": "Passkeys are a modern way to authenticate users without the need for passwords. They are stored securely in your browser and can be used to log in to the WireGuard Portal.",
+ "active-description": "At least one passkey is currently active for your user account.",
+ "inactive-description": "No passkeys are currently registered for your user account. Press the button below to register a new passkey.",
+ "table": {
+ "name": "Name",
+ "created": "Created",
+ "actions": ""
+ },
+ "credentials-list": "Currently registered Passkeys",
+ "modal-delete": {
+ "headline": "Delete Passkey",
+ "abstract": "Are you sure you want to delete this passkey? You will not be able to log in with this passkey anymore.",
+ "created": "Created:",
+ "button-delete": "Delete",
+ "button-cancel": "Cancel"
+ },
+ "button-rename-title": "Rename",
+ "button-rename-text": "Rename the passkey.",
+ "button-save-title": "Save",
+ "button-save-text": "Save the new name of the passkey.",
+ "button-cancel-title": "Cancel",
+ "button-cancel-text": "Cancel the renaming of the passkey.",
+ "button-delete-title": "Delete",
+ "button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.",
+ "button-register-title": "Register Passkey",
+ "button-register-text": "Register a new Passkey to secure your account."
}
},
"audit": {
diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js
index b7619b2..50c0d09 100644
--- a/frontend/src/stores/auth.js
+++ b/frontend/src/stores/auth.js
@@ -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
+ }
}
});
diff --git a/frontend/src/stores/profile.js b/frontend/src/stores/profile.js
index 243622a..ba3798c 100644
--- a/frontend/src/stores/profile.js
+++ b/frontend/src/stores/profile.js
@@ -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",
diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue
index 1523c27..d5037e2 100644
--- a/frontend/src/views/LoginView.vue
+++ b/frontend/src/views/LoginView.vue
@@ -1,6 +1,6 @@
@@ -69,4 +91,86 @@ onMounted(async () => {
+
+
+
{{ $t('settings.webauthn.headline') }}
+
{{ $t('settings.webauthn.abstract') }}
+
+
{{ $t('settings.webauthn.active-description') }}
+
{{ $t('settings.webauthn.inactive-description') }}
+
+
+
+
+
+
+
+
+
{{ $t('settings.webauthn.credentials-list') }}
+
+
+
+ {{ $t('settings.webauthn.table.name') }} |
+ {{ $t('settings.webauthn.table.created') }} |
+ {{ $t('settings.webauthn.table.actions') }} |
+
+
+
+
+
+
+
+
+
+ {{ credential.Name }}
+
+ |
+
+ {{ credential.CreatedAt }}
+ |
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
{{ selectedCredential.Name }} ({{ $t('settings.webauthn.modal-delete.created') }} {{ selectedCredential.CreatedAt }})
+
{{ $t('settings.webauthn.modal-delete.abstract') }}
+
+
+
+
+
+
+
diff --git a/go.mod b/go.mod
index 9775d6b..62bdd8c 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@ require (
github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-pkgz/routegroup v1.4.1
github.com/go-playground/validator/v10 v10.26.0
+ github.com/go-webauthn/webauthn v0.12.3
github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.22.0
@@ -39,6 +40,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
@@ -51,9 +53,12 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/go-test/deep v1.1.1 // indirect
+ github.com/go-webauthn/x v0.1.20 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/go-tpm v0.9.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.4 // indirect
@@ -69,6 +74,7 @@ require (
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/microsoft/go-mssqldb v1.8.0 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -78,6 +84,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.39.0 // indirect
diff --git a/go.sum b/go.sum
index ddfdb51..b5e7af0 100644
--- a/go.sum
+++ b/go.sum
@@ -44,6 +44,8 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
+github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -79,10 +81,14 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
+github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
+github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY=
+github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw=
+github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
@@ -90,6 +96,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
+github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -154,6 +162,8 @@ github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3ao
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -203,6 +213,8 @@ github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
diff --git a/internal/adapters/database.go b/internal/adapters/database.go
index 0ae20ad..14726ae 100644
--- a/internal/adapters/database.go
+++ b/internal/adapters/database.go
@@ -220,6 +220,8 @@ func (r *SqlRepo) preCheck() error {
func (r *SqlRepo) migrate() error {
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
+ slog.Debug("running migration: user webauthn credentials", "result",
+ r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{}))
slog.Debug("running migration: peer status", "result", r.db.AutoMigrate(&domain.PeerStatus{}))
@@ -746,7 +748,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
var user domain.User
- err := r.db.WithContext(ctx).First(&user, id).Error
+ err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
@@ -764,7 +766,7 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var users []domain.User
- err := r.db.WithContext(ctx).Where("email = ?", email).Find(&users).Error
+ err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
}
@@ -785,11 +787,26 @@ func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Use
return &user, nil
}
+// GetUserByWebAuthnCredential returns the user with the given webauthn credential id.
+func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
+ var credential domain.UserWebauthnCredential
+
+ err := r.db.WithContext(ctx).Where("credential_identifier = ?", credentialIdBase64).First(&credential).Error
+ if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, domain.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return r.GetUser(ctx, domain.UserIdentifier(credential.UserIdentifier))
+}
+
// GetAllUsers returns all users.
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
var users []domain.User
- err := r.db.WithContext(ctx).Find(&users).Error
+ err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error
if err != nil {
return nil, err
}
@@ -808,6 +825,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
Or("firstname LIKE ?", searchValue).
Or("lastname LIKE ?", searchValue).
Or("email LIKE ?", searchValue).
+ Preload("WebAuthnCredentialList").
Find(&users).Error
if err != nil {
return nil, err
@@ -853,7 +871,7 @@ func (r *SqlRepo) SaveUser(
// DeleteUser deletes the user with the given id.
func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error {
- err := r.db.WithContext(ctx).Delete(&domain.User{}, id).Error
+ err := r.db.WithContext(ctx).Unscoped().Select(clause.Associations).Delete(&domain.User{Identifier: id}).Error
if err != nil {
return err
}
@@ -897,6 +915,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
return err
}
+ err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Unscoped().Model(user).Association("WebAuthnCredentialList").Unscoped().Replace(user.WebAuthnCredentialList)
+ if err != nil {
+ return fmt.Errorf("failed to update users webauthn credentials: %w", err)
+ }
+
return nil
}
diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json
index 3ca06a1..0658046 100644
--- a/internal/app/api/core/assets/doc/v0_swagger.json
+++ b/internal/app/api/core/assets/doc/v0_swagger.json
@@ -129,6 +129,152 @@
}
}
},
+ "/auth/webauthn/credential/{id}": {
+ "put": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Update a WebAuthn credential.",
+ "operationId": "auth_handleWebAuthnCredentialsPut",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Base64 encoded Credential ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Credential name",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/model.WebAuthnCredentialRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.WebAuthnCredentialResponse"
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Delete a WebAuthn credential.",
+ "operationId": "auth_handleWebAuthnCredentialsDelete",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Base64 encoded Credential ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.WebAuthnCredentialResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/webauthn/credentials": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Get all available external login providers.",
+ "operationId": "auth_handleWebAuthnCredentialsGet",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.WebAuthnCredentialResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/webauthn/login/finish": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Finish the WebAuthn login process.",
+ "operationId": "auth_handleWebAuthnLoginFinish",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/model.User"
+ }
+ }
+ }
+ }
+ },
+ "/auth/webauthn/register/finish": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Finish the WebAuthn registration process.",
+ "operationId": "auth_handleWebAuthnRegisterFinish",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "\"\"",
+ "description": "Credential name",
+ "name": "credential_name",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/model.WebAuthnCredentialResponse"
+ }
+ }
+ }
+ }
+ }
+ },
"/auth/{provider}/callback": {
"get": {
"produces": [
@@ -2093,6 +2239,9 @@
},
"SelfProvisioning": {
"type": "boolean"
+ },
+ "WebAuthnEnabled": {
+ "type": "boolean"
}
}
},
@@ -2161,6 +2310,28 @@
"type": "string"
}
}
+ },
+ "model.WebAuthnCredentialRequest": {
+ "type": "object",
+ "properties": {
+ "Name": {
+ "type": "string"
+ }
+ }
+ },
+ "model.WebAuthnCredentialResponse": {
+ "type": "object",
+ "properties": {
+ "CreatedAt": {
+ "type": "string"
+ },
+ "ID": {
+ "type": "string"
+ },
+ "Name": {
+ "type": "string"
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml
index ab42b60..e8778b2 100644
--- a/internal/app/api/core/assets/doc/v0_swagger.yaml
+++ b/internal/app/api/core/assets/doc/v0_swagger.yaml
@@ -387,6 +387,8 @@ definitions:
type: boolean
SelfProvisioning:
type: boolean
+ WebAuthnEnabled:
+ type: boolean
type: object
model.User:
properties:
@@ -433,6 +435,20 @@ definitions:
Source:
type: string
type: object
+ model.WebAuthnCredentialRequest:
+ properties:
+ Name:
+ type: string
+ type: object
+ model.WebAuthnCredentialResponse:
+ properties:
+ CreatedAt:
+ type: string
+ ID:
+ type: string
+ Name:
+ type: string
+ type: object
info:
contact:
name: WireGuard Portal Developers
@@ -548,6 +564,102 @@ paths:
summary: Get information about the currently logged-in user.
tags:
- Authentication
+ /auth/webauthn/credential/{id}:
+ delete:
+ operationId: auth_handleWebAuthnCredentialsDelete
+ parameters:
+ - description: Base64 encoded Credential ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.WebAuthnCredentialResponse'
+ type: array
+ summary: Delete a WebAuthn credential.
+ tags:
+ - Authentication
+ put:
+ operationId: auth_handleWebAuthnCredentialsPut
+ parameters:
+ - description: Base64 encoded Credential ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Credential name
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/model.WebAuthnCredentialRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.WebAuthnCredentialResponse'
+ type: array
+ summary: Update a WebAuthn credential.
+ tags:
+ - Authentication
+ /auth/webauthn/credentials:
+ get:
+ operationId: auth_handleWebAuthnCredentialsGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.WebAuthnCredentialResponse'
+ type: array
+ summary: Get all available external login providers.
+ tags:
+ - Authentication
+ /auth/webauthn/login/finish:
+ post:
+ operationId: auth_handleWebAuthnLoginFinish
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/model.User'
+ summary: Finish the WebAuthn login process.
+ tags:
+ - Authentication
+ /auth/webauthn/register/finish:
+ post:
+ operationId: auth_handleWebAuthnRegisterFinish
+ parameters:
+ - default: '""'
+ description: Credential name
+ in: query
+ name: credential_name
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/model.WebAuthnCredentialResponse'
+ type: array
+ summary: Finish the WebAuthn registration process.
+ tags:
+ - Authentication
/config/frontend.js:
get:
operationId: config_handleConfigJsGet
diff --git a/internal/app/api/v0/handlers/endpoint_authentication.go b/internal/app/api/v0/handlers/endpoint_authentication.go
index 56a889b..de412e3 100644
--- a/internal/app/api/v0/handlers/endpoint_authentication.go
+++ b/internal/app/api/v0/handlers/endpoint_authentication.go
@@ -28,12 +28,54 @@ type AuthenticationService interface {
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
}
+type WebAuthnService interface {
+ Enabled() bool
+ StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
+ responseOptions []byte,
+ sessionData []byte,
+ err error,
+ )
+ FinishWebAuthnRegistration(
+ ctx context.Context,
+ userId domain.UserIdentifier,
+ name string,
+ sessionDataAsJSON []byte,
+ r *http.Request,
+ ) ([]domain.UserWebauthnCredential, error)
+ GetCredentials(
+ ctx context.Context,
+ userId domain.UserIdentifier,
+ ) ([]domain.UserWebauthnCredential, error)
+ RemoveCredential(
+ ctx context.Context,
+ userId domain.UserIdentifier,
+ credentialIdBase64 string,
+ ) ([]domain.UserWebauthnCredential, error)
+ UpdateCredential(
+ ctx context.Context,
+ userId domain.UserIdentifier,
+ credentialIdBase64 string,
+ name string,
+ ) ([]domain.UserWebauthnCredential, error)
+ StartWebAuthnLogin(_ context.Context) (
+ optionsAsJSON []byte,
+ sessionDataAsJSON []byte,
+ err error,
+ )
+ FinishWebAuthnLogin(
+ ctx context.Context,
+ sessionDataAsJSON []byte,
+ r *http.Request,
+ ) (*domain.User, error)
+}
+
type AuthEndpoint struct {
cfg *config.Config
authService AuthenticationService
authenticator Authenticator
session Session
validate Validator
+ webAuthn WebAuthnService
}
func NewAuthEndpoint(
@@ -42,6 +84,7 @@ func NewAuthEndpoint(
session Session,
validator Validator,
authService AuthenticationService,
+ webAuthn WebAuthnService,
) AuthEndpoint {
return AuthEndpoint{
cfg: cfg,
@@ -49,6 +92,7 @@ func NewAuthEndpoint(
authenticator: authenticator,
session: session,
validate: validator,
+ webAuthn: webAuthn,
}
}
@@ -65,6 +109,19 @@ func (e AuthEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet())
apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet())
+ apiGroup.HandleFunc("POST /webauthn/login/start", e.handleWebAuthnLoginStart())
+ apiGroup.HandleFunc("POST /webauthn/login/finish", e.handleWebAuthnLoginFinish())
+ apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /webauthn/credentials",
+ e.handleWebAuthnCredentialsGet())
+ apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/start",
+ e.handleWebAuthnRegisterStart())
+ apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/finish",
+ e.handleWebAuthnRegisterFinish())
+ apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("DELETE /webauthn/credential/{id}",
+ e.handleWebAuthnCredentialsDelete())
+ apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("PUT /webauthn/credential/{id}",
+ e.handleWebAuthnCredentialsPut())
+
apiGroup.HandleFunc("POST /login", e.handleLoginPost())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
}
@@ -389,3 +446,237 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
return true
}
+
+// handleWebAuthnCredentialsGet returns a gorm Handler function.
+//
+// @ID auth_handleWebAuthnCredentialsGet
+// @Tags Authentication
+// @Summary Get all available external login providers.
+// @Produce json
+// @Success 200 {object} []model.WebAuthnCredentialResponse
+// @Router /auth/webauthn/credentials [get]
+func (e AuthEndpoint) handleWebAuthnCredentialsGet() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !e.webAuthn.Enabled() {
+ respond.JSON(w, http.StatusOK, []model.WebAuthnCredentialResponse{})
+ return
+ }
+
+ currentSession := e.session.GetData(r.Context())
+
+ userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
+
+ credentials, err := e.webAuthn.GetCredentials(r.Context(), userIdentifier)
+ if err != nil {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: err.Error()})
+ return
+ }
+
+ respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
+ }
+}
+
+// handleWebAuthnCredentialsDelete returns a gorm Handler function.
+//
+// @ID auth_handleWebAuthnCredentialsDelete
+// @Tags Authentication
+// @Summary Delete a WebAuthn credential.
+// @Param id path string true "Base64 encoded Credential ID"
+// @Produce json
+// @Success 200 {object} []model.WebAuthnCredentialResponse
+// @Router /auth/webauthn/credential/{id} [delete]
+func (e AuthEndpoint) handleWebAuthnCredentialsDelete() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !e.webAuthn.Enabled() {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
+ return
+ }
+
+ currentSession := e.session.GetData(r.Context())
+
+ userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
+
+ credentialId := Base64UrlDecode(request.Path(r, "id"))
+
+ credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId)
+ if err != nil {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: err.Error()})
+ return
+ }
+
+ respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
+ }
+}
+
+// handleWebAuthnCredentialsPut returns a gorm Handler function.
+//
+// @ID auth_handleWebAuthnCredentialsPut
+// @Tags Authentication
+// @Summary Update a WebAuthn credential.
+// @Param id path string true "Base64 encoded Credential ID"
+// @Param request body model.WebAuthnCredentialRequest true "Credential name"
+// @Produce json
+// @Success 200 {object} []model.WebAuthnCredentialResponse
+// @Router /auth/webauthn/credential/{id} [put]
+func (e AuthEndpoint) handleWebAuthnCredentialsPut() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !e.webAuthn.Enabled() {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
+ return
+ }
+
+ currentSession := e.session.GetData(r.Context())
+
+ userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
+
+ credentialId := Base64UrlDecode(request.Path(r, "id"))
+ var req model.WebAuthnCredentialRequest
+ if err := request.BodyJson(r, &req); err != nil {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: err.Error()})
+ return
+ }
+
+ credentials, err := e.webAuthn.UpdateCredential(r.Context(), userIdentifier, credentialId, req.Name)
+ if err != nil {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: err.Error()})
+ return
+ }
+
+ respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
+ }
+}
+
+func (e AuthEndpoint) handleWebAuthnRegisterStart() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !e.webAuthn.Enabled() {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
+ return
+ }
+
+ currentSession := e.session.GetData(r.Context())
+
+ userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
+
+ options, sessionData, err := e.webAuthn.StartWebAuthnRegistration(r.Context(), userIdentifier)
+ if err != nil {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: err.Error()})
+ return
+ }
+
+ currentSession.WebAuthnData = string(sessionData)
+ e.session.SetData(r.Context(), currentSession)
+
+ respond.Data(w, http.StatusOK, "application/json", options)
+ }
+}
+
+// handleWebAuthnRegisterFinish returns a gorm Handler function.
+//
+// @ID auth_handleWebAuthnRegisterFinish
+// @Tags Authentication
+// @Summary Finish the WebAuthn registration process.
+// @Param credential_name query string false "Credential name" default("")
+// @Produce json
+// @Success 200 {object} []model.WebAuthnCredentialResponse
+// @Router /auth/webauthn/register/finish [post]
+func (e AuthEndpoint) handleWebAuthnRegisterFinish() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !e.webAuthn.Enabled() {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
+ return
+ }
+
+ name := request.QueryDefault(r, "credential_name", "")
+
+ currentSession := e.session.GetData(r.Context())
+
+ webAuthnSessionData := []byte(currentSession.WebAuthnData)
+ currentSession.WebAuthnData = "" // clear the session data
+ e.session.SetData(r.Context(), currentSession)
+
+ credentials, err := e.webAuthn.FinishWebAuthnRegistration(
+ r.Context(),
+ domain.UserIdentifier(currentSession.UserIdentifier),
+ name,
+ webAuthnSessionData,
+ r)
+ if err != nil {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: err.Error()})
+ return
+ }
+
+ respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
+ }
+}
+
+func (e AuthEndpoint) handleWebAuthnLoginStart() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !e.webAuthn.Enabled() {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
+ return
+ }
+
+ currentSession := e.session.GetData(r.Context())
+
+ options, sessionData, err := e.webAuthn.StartWebAuthnLogin(r.Context())
+ if err != nil {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: err.Error()})
+ return
+ }
+
+ currentSession.WebAuthnData = string(sessionData)
+ e.session.SetData(r.Context(), currentSession)
+
+ respond.Data(w, http.StatusOK, "application/json", options)
+ }
+}
+
+// handleWebAuthnLoginFinish returns a gorm Handler function.
+//
+// @ID auth_handleWebAuthnLoginFinish
+// @Tags Authentication
+// @Summary Finish the WebAuthn login process.
+// @Produce json
+// @Success 200 {object} model.User
+// @Router /auth/webauthn/login/finish [post]
+func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !e.webAuthn.Enabled() {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
+ return
+ }
+
+ currentSession := e.session.GetData(r.Context())
+
+ webAuthnSessionData := []byte(currentSession.WebAuthnData)
+ currentSession.WebAuthnData = "" // clear the session data
+ e.session.SetData(r.Context(), currentSession)
+
+ user, err := e.webAuthn.FinishWebAuthnLogin(
+ r.Context(),
+ webAuthnSessionData,
+ r)
+ if err != nil {
+ respond.JSON(w, http.StatusBadRequest,
+ model.Error{Code: http.StatusBadRequest, Message: err.Error()})
+ return
+ }
+
+ e.setAuthenticatedUser(r, user)
+
+ respond.JSON(w, http.StatusOK, model.NewUser(user, false))
+ }
+}
diff --git a/internal/app/api/v0/handlers/endpoint_config.go b/internal/app/api/v0/handlers/endpoint_config.go
index c81c2c9..d791f88 100644
--- a/internal/app/api/v0/handlers/endpoint_config.go
+++ b/internal/app/api/v0/handlers/endpoint_config.go
@@ -15,6 +15,7 @@ import (
"github.com/h44z/wg-portal/internal/app/api/core/respond"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
)
//go:embed frontend_config.js.gotpl
@@ -46,7 +47,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/config")
apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet())
- apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /settings", e.handleSettingsGet())
+ apiGroup.HandleFunc("GET /settings", e.handleSettingsGet())
}
// handleConfigJsGet returns a gorm Handler function.
@@ -93,11 +94,21 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
// @Router /config/settings [get]
func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- respond.JSON(w, http.StatusOK, model.Settings{
- MailLinkOnly: e.cfg.Mail.LinkOnly,
- PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
- SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
- ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
- })
+ sessionUser := domain.GetUserInfo(r.Context())
+
+ // For anonymous users, we return the settings object with minimal information
+ if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" {
+ respond.JSON(w, http.StatusOK, model.Settings{
+ WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
+ })
+ } else {
+ respond.JSON(w, http.StatusOK, model.Settings{
+ MailLinkOnly: e.cfg.Mail.LinkOnly,
+ PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
+ SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
+ ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
+ WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
+ })
+ }
}
}
diff --git a/internal/app/api/v0/handlers/web_session.go b/internal/app/api/v0/handlers/web_session.go
index dd4ca6c..1e12eaa 100644
--- a/internal/app/api/v0/handlers/web_session.go
+++ b/internal/app/api/v0/handlers/web_session.go
@@ -31,6 +31,8 @@ type SessionData struct {
OauthProvider string
OauthReturnTo string
+ WebAuthnData string
+
CsrfToken string
}
diff --git a/internal/app/api/v0/model/models.go b/internal/app/api/v0/model/models.go
index e2298bc..a6bbc75 100644
--- a/internal/app/api/v0/model/models.go
+++ b/internal/app/api/v0/model/models.go
@@ -10,4 +10,5 @@ type Settings struct {
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
SelfProvisioning bool `json:"SelfProvisioning"`
ApiAdminOnly bool `json:"ApiAdminOnly"`
+ WebAuthnEnabled bool `json:"WebAuthnEnabled"`
}
diff --git a/internal/app/api/v0/model/models_authentication.go b/internal/app/api/v0/model/models_authentication.go
index 1966405..b7283b1 100644
--- a/internal/app/api/v0/model/models_authentication.go
+++ b/internal/app/api/v0/model/models_authentication.go
@@ -1,6 +1,11 @@
package model
-import "github.com/h44z/wg-portal/internal/domain"
+import (
+ "slices"
+ "strings"
+
+ "github.com/h44z/wg-portal/internal/domain"
+)
type LoginProviderInfo struct {
Identifier string `json:"Identifier" example:"google"`
@@ -39,3 +44,32 @@ type OauthInitiationResponse struct {
RedirectUrl string
State string
}
+
+type WebAuthnCredentialRequest struct {
+ Name string `json:"Name"`
+}
+type WebAuthnCredentialResponse struct {
+ ID string `json:"ID"`
+ Name string `json:"Name"`
+ CreatedAt string `json:"CreatedAt"`
+}
+
+func NewWebAuthnCredentialResponse(src domain.UserWebauthnCredential) WebAuthnCredentialResponse {
+ return WebAuthnCredentialResponse{
+ ID: src.CredentialIdentifier,
+ Name: src.DisplayName,
+ CreatedAt: src.CreatedAt.Format("2006-01-02 15:04:05"),
+ }
+}
+
+func NewWebAuthnCredentialResponses(src []domain.UserWebauthnCredential) []WebAuthnCredentialResponse {
+ credentials := make([]WebAuthnCredentialResponse, len(src))
+ for i := range src {
+ credentials[i] = NewWebAuthnCredentialResponse(src[i])
+ }
+ // Sort by CreatedAt, newest first
+ slices.SortFunc(credentials, func(i, j WebAuthnCredentialResponse) int {
+ return strings.Compare(i.CreatedAt, j.CreatedAt)
+ })
+ return credentials
+}
diff --git a/internal/app/auth/webauthn.go b/internal/app/auth/webauthn.go
new file mode 100644
index 0000000..f9c9874
--- /dev/null
+++ b/internal/app/auth/webauthn.go
@@ -0,0 +1,301 @@
+package auth
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+
+ "github.com/h44z/wg-portal/internal/app"
+ "github.com/h44z/wg-portal/internal/app/audit"
+ "github.com/h44z/wg-portal/internal/config"
+ "github.com/h44z/wg-portal/internal/domain"
+)
+
+type WebAuthnUserManager interface {
+ // GetUser returns a user by its identifier.
+ GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
+ // GetUserByWebAuthnCredential returns a user by its WebAuthn ID.
+ GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
+ // UpdateUser updates an existing user in the database.
+ UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
+}
+
+type WebAuthnAuthenticator struct {
+ webAuthn *webauthn.WebAuthn
+ users WebAuthnUserManager
+ bus EventBus
+}
+
+func NewWebAuthnAuthenticator(cfg *config.Config, bus EventBus, users WebAuthnUserManager) (
+ *WebAuthnAuthenticator,
+ error,
+) {
+ if !cfg.Auth.WebAuthn.Enabled {
+ return nil, nil
+ }
+
+ extUrl, err := url.Parse(cfg.Web.ExternalUrl)
+ if err != nil {
+ return nil, errors.New("failed to parse external URL - required for WebAuthn RP ID")
+ }
+
+ rpId := extUrl.Hostname()
+ if rpId == "" {
+ return nil, errors.New("failed to determine Webauthn RPID")
+ }
+
+ // Initialize the WebAuthn authenticator with the provided configuration
+ awCfg := &webauthn.Config{
+ RPID: rpId,
+ RPDisplayName: cfg.Web.SiteTitle,
+ RPOrigins: []string{cfg.Web.ExternalUrl},
+ }
+
+ webAuthn, err := webauthn.New(awCfg)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create Webauthn instance: %w", err)
+ }
+
+ return &WebAuthnAuthenticator{
+ webAuthn: webAuthn,
+ users: users,
+ bus: bus,
+ }, nil
+}
+
+func (a *WebAuthnAuthenticator) Enabled() bool {
+ return a != nil && a.webAuthn != nil
+}
+
+func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
+ optionsAsJSON []byte,
+ sessionDataAsJSON []byte,
+ err error,
+) {
+ user, err := a.users.GetUser(ctx, userId)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get user: %w", err)
+ }
+ if user.IsLocked() || user.IsDisabled() {
+ return nil, nil, errors.New("user is locked") // adding passkey to locked user is not allowed
+ }
+
+ if user.WebAuthnId == "" {
+ user.GenerateWebAuthnId()
+ user, err = a.users.UpdateUser(ctx, user)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err)
+ }
+ }
+
+ options, sessionData, err := a.webAuthn.BeginRegistration(user,
+ webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
+ )
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
+ }
+
+ optionsAsJSON, err = json.Marshal(options)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
+ }
+ sessionDataAsJSON, err = json.Marshal(sessionData)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
+ }
+
+ return optionsAsJSON, sessionDataAsJSON, nil
+}
+
+func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration(
+ ctx context.Context,
+ userId domain.UserIdentifier,
+ name string,
+ sessionDataAsJSON []byte,
+ r *http.Request,
+) ([]domain.UserWebauthnCredential, error) {
+ user, err := a.users.GetUser(ctx, userId)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+ if user.IsLocked() || user.IsDisabled() {
+ return nil, errors.New("user is locked") // adding passkey to locked user is not allowed
+ }
+
+ var webAuthnData webauthn.SessionData
+ err = json.Unmarshal(sessionDataAsJSON, &webAuthnData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
+ }
+
+ credential, err := a.webAuthn.FinishRegistration(user, webAuthnData, r)
+ if err != nil {
+ return nil, err
+ }
+
+ if name == "" {
+ name = fmt.Sprintf("Passkey %d", len(user.WebAuthnCredentialList)+1) // fallback name
+ }
+
+ // Add the credential to the user
+ err = user.AddCredential(userId, name, *credential)
+ if err != nil {
+ return nil, err
+ }
+
+ user, err = a.users.UpdateUser(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+
+ return user.WebAuthnCredentialList, nil
+}
+
+func (a *WebAuthnAuthenticator) GetCredentials(
+ ctx context.Context,
+ userId domain.UserIdentifier,
+) ([]domain.UserWebauthnCredential, error) {
+ user, err := a.users.GetUser(ctx, userId)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+
+ return user.WebAuthnCredentialList, nil
+}
+
+func (a *WebAuthnAuthenticator) RemoveCredential(
+ ctx context.Context,
+ userId domain.UserIdentifier,
+ credentialIdBase64 string,
+) ([]domain.UserWebauthnCredential, error) {
+ user, err := a.users.GetUser(ctx, userId)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+
+ user.RemoveCredential(credentialIdBase64)
+ user, err = a.users.UpdateUser(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+
+ return user.WebAuthnCredentialList, nil
+}
+
+func (a *WebAuthnAuthenticator) UpdateCredential(
+ ctx context.Context,
+ userId domain.UserIdentifier,
+ credentialIdBase64 string,
+ name string,
+) ([]domain.UserWebauthnCredential, error) {
+ user, err := a.users.GetUser(ctx, userId)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+
+ err = user.UpdateCredential(credentialIdBase64, name)
+ if err != nil {
+ return nil, err
+ }
+
+ user, err = a.users.UpdateUser(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+
+ return user.WebAuthnCredentialList, nil
+}
+
+func (a *WebAuthnAuthenticator) StartWebAuthnLogin(_ context.Context) (
+ optionsAsJSON []byte,
+ sessionDataAsJSON []byte,
+ err error,
+) {
+ options, sessionData, err := a.webAuthn.BeginDiscoverableLogin()
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to begin WebAuthn login: %w", err)
+ }
+
+ optionsAsJSON, err = json.Marshal(options)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
+ }
+ sessionDataAsJSON, err = json.Marshal(sessionData)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
+ }
+
+ return optionsAsJSON, sessionDataAsJSON, nil
+}
+
+func (a *WebAuthnAuthenticator) FinishWebAuthnLogin(
+ ctx context.Context,
+ sessionDataAsJSON []byte,
+ r *http.Request,
+) (*domain.User, error) {
+
+ var webAuthnData webauthn.SessionData
+ err := json.Unmarshal(sessionDataAsJSON, &webAuthnData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
+ }
+
+ // switch to admin context for user lookup
+ ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo())
+
+ credential, err := a.webAuthn.FinishDiscoverableLogin(a.findUserForWebAuthnSecretFn(ctx), webAuthnData, r)
+ if err != nil {
+ return nil, err
+ }
+
+ // Find the user by the WebAuthn ID
+ user, err := a.users.GetUserByWebAuthnCredential(ctx,
+ base64.StdEncoding.EncodeToString(credential.ID))
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
+ }
+
+ if user.IsLocked() || user.IsDisabled() {
+ a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
+ Ctx: ctx,
+ Source: "passkey",
+ Event: audit.AuthEvent{
+ Username: string(user.Identifier), Error: "User is locked",
+ },
+ })
+ return nil, errors.New("user is locked") // login with passkey is not allowed
+ }
+
+ a.bus.Publish(app.TopicAuthLogin, user.Identifier)
+ a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
+ Ctx: ctx,
+ Source: "passkey",
+ Event: audit.AuthEvent{
+ Username: string(user.Identifier),
+ },
+ })
+
+ return user, nil
+}
+
+func (a *WebAuthnAuthenticator) findUserForWebAuthnSecretFn(ctx context.Context) func(rawID, userHandle []byte) (
+ user webauthn.User,
+ err error,
+) {
+ return func(rawID, userHandle []byte) (webauthn.User, error) {
+ // Find the user by the WebAuthn ID
+ user, err := a.users.GetUserByWebAuthnCredential(ctx, base64.StdEncoding.EncodeToString(rawID))
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
+ }
+
+ return user, nil
+ }
+}
diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go
index 9511e32..cd8fb5b 100644
--- a/internal/app/users/user_manager.go
+++ b/internal/app/users/user_manager.go
@@ -25,6 +25,8 @@ type UserDatabaseRepo interface {
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
// GetUserByEmail returns the user with the given email address.
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
+ // GetUserByWebAuthnCredential returns the user for the given WebAuthn credential ID.
+ GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
// GetAllUsers returns all users.
GetAllUsers(ctx context.Context) ([]domain.User, error)
// FindUsers returns all users matching the search string.
@@ -129,6 +131,25 @@ func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User
return user, nil
}
+// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential.
+func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
+
+ user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64)
+ if err != nil {
+ return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err)
+ }
+
+ if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil {
+ return nil, err
+ }
+
+ peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
+
+ user.LinkedPeerCount = len(peers)
+
+ return user, nil
+}
+
// GetAllUsers returns all users.
func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
diff --git a/internal/config/auth.go b/internal/config/auth.go
index 3132fb7..ba68b12 100644
--- a/internal/config/auth.go
+++ b/internal/config/auth.go
@@ -16,6 +16,8 @@ type Auth struct {
OAuth []OAuthProvider `yaml:"oauth"`
// Ldap contains a list of LDAP providers.
Ldap []LdapProvider `yaml:"ldap"`
+ // Webauthn contains the configuration for the WebAuthn authenticator.
+ WebAuthn WebauthnConfig `yaml:"webauthn"`
}
// BaseFields contains the basic fields that are used to map user information from the authentication providers.
@@ -245,3 +247,9 @@ type OAuthProvider struct {
// If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"`
}
+
+// WebauthnConfig contains the configuration for the WebAuthn authenticator.
+type WebauthnConfig struct {
+ // Enabled specifies whether WebAuthn is enabled.
+ Enabled bool `yaml:"enabled"`
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index dedebac..2096a3f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -164,6 +164,8 @@ func defaultConfig() *Config {
cfg.Webhook.Authentication = ""
cfg.Webhook.Timeout = 10 * time.Second
+ cfg.Auth.WebAuthn.Enabled = true
+
return cfg
}
diff --git a/internal/domain/user.go b/internal/domain/user.go
index c85e78e..43a8b47 100644
--- a/internal/domain/user.go
+++ b/internal/domain/user.go
@@ -2,9 +2,16 @@ package domain
import (
"crypto/subtle"
+ "encoding/base64"
+ "encoding/json"
"errors"
+ "fmt"
+ "slices"
+ "strings"
"time"
+ "github.com/go-webauthn/webauthn/webauthn"
+ "github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
@@ -43,6 +50,10 @@ type User struct {
Locked *time.Time `gorm:"index;column:locked"` // if this field is set, the user is locked and can no longer login (WireGuard peers still can connect)
LockedReason string // the reason why the user has been locked
+ // Passwordless authentication
+ WebAuthnId string `gorm:"column:webauthn_id"` // the webauthn id of the user, used for webauthn authentication
+ WebAuthnCredentialList []UserWebauthnCredential `gorm:"foreignKey:user_identifier"` // the webauthn credentials of the user, used for webauthn authentication
+
// API token for REST API access
ApiToken string `form:"api_token" binding:"omitempty"`
ApiTokenCreated *time.Time
@@ -157,3 +168,148 @@ func (u *User) CopyCalculatedAttributes(src *User) {
u.BaseModel = src.BaseModel
u.LinkedPeerCount = src.LinkedPeerCount
}
+
+// region webauthn
+
+func (u *User) WebAuthnID() []byte {
+ decodeString, err := base64.StdEncoding.DecodeString(u.WebAuthnId)
+ if err != nil {
+ return nil
+ }
+
+ return decodeString
+}
+
+func (u *User) GenerateWebAuthnId() {
+ randomUid1 := uuid.New().String() // 32 hex digits + 4 dashes
+ randomUid2 := uuid.New().String() // 32 hex digits + 4 dashes
+ webAuthnId := []byte(strings.ReplaceAll(fmt.Sprintf("%s%s", randomUid1, randomUid2), "-", "")) // 64 hex digits
+
+ u.WebAuthnId = base64.StdEncoding.EncodeToString(webAuthnId)
+}
+
+func (u *User) WebAuthnName() string {
+ return string(u.Identifier)
+}
+
+func (u *User) WebAuthnDisplayName() string {
+ var userName string
+ switch {
+ case u.Firstname != "" && u.Lastname != "":
+ userName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname)
+ case u.Firstname != "":
+ userName = u.Firstname
+ case u.Lastname != "":
+ userName = u.Lastname
+ default:
+ userName = string(u.Identifier)
+ }
+
+ return userName
+}
+
+func (u *User) WebAuthnCredentials() []webauthn.Credential {
+ credentials := make([]webauthn.Credential, len(u.WebAuthnCredentialList))
+ for i, cred := range u.WebAuthnCredentialList {
+ credential, err := cred.GetCredential()
+ if err != nil {
+ continue
+ }
+ credentials[i] = credential
+ }
+ return credentials
+}
+
+func (u *User) AddCredential(userId UserIdentifier, name string, credential webauthn.Credential) error {
+ cred, err := NewUserWebauthnCredential(userId, name, credential)
+ if err != nil {
+ return err
+ }
+
+ // Check if the credential already exists
+ for _, c := range u.WebAuthnCredentialList {
+ if c.GetCredentialId() == string(credential.ID) {
+ return errors.New("credential already exists")
+ }
+ }
+
+ u.WebAuthnCredentialList = append(u.WebAuthnCredentialList, cred)
+ return nil
+}
+
+func (u *User) UpdateCredential(credentialIdBase64, name string) error {
+ for i, c := range u.WebAuthnCredentialList {
+ if c.CredentialIdentifier == credentialIdBase64 {
+ u.WebAuthnCredentialList[i].DisplayName = name
+ return nil
+ }
+ }
+
+ return errors.New("credential not found")
+}
+
+func (u *User) RemoveCredential(credentialIdBase64 string) {
+ u.WebAuthnCredentialList = slices.DeleteFunc(u.WebAuthnCredentialList, func(e UserWebauthnCredential) bool {
+ return e.CredentialIdentifier == credentialIdBase64
+ })
+}
+
+type UserWebauthnCredential struct {
+ UserIdentifier string `gorm:"primaryKey;column:user_identifier"` // the user identifier
+ CredentialIdentifier string `gorm:"primaryKey;uniqueIndex;column:credential_identifier"` // base64 encoded credential id
+ CreatedAt time.Time `gorm:"column:created_at"` // the time when the credential was created
+ DisplayName string `gorm:"column:display_name"` // the display name of the credential
+ SerializedCredential string `gorm:"column:serialized_credential"` // JSON and base64 encoded credential
+}
+
+func NewUserWebauthnCredential(userIdentifier UserIdentifier, name string, credential webauthn.Credential) (
+ UserWebauthnCredential,
+ error,
+) {
+ c := UserWebauthnCredential{
+ UserIdentifier: string(userIdentifier),
+ CreatedAt: time.Now(),
+ DisplayName: name,
+ CredentialIdentifier: base64.StdEncoding.EncodeToString(credential.ID),
+ }
+
+ err := c.SetCredential(credential)
+ if err != nil {
+ return c, err
+ }
+
+ return c, nil
+}
+
+func (c *UserWebauthnCredential) SetCredential(credential webauthn.Credential) error {
+ jsonData, err := json.Marshal(credential)
+ if err != nil {
+ return fmt.Errorf("failed to marshal credential: %w", err)
+ }
+
+ c.SerializedCredential = base64.StdEncoding.EncodeToString(jsonData)
+
+ return nil
+}
+
+func (c *UserWebauthnCredential) GetCredential() (webauthn.Credential, error) {
+ jsonData, err := base64.StdEncoding.DecodeString(c.SerializedCredential)
+ if err != nil {
+ return webauthn.Credential{}, fmt.Errorf("failed to decode base64 credential: %w", err)
+ }
+
+ var credential webauthn.Credential
+ if err := json.Unmarshal(jsonData, &credential); err != nil {
+ return webauthn.Credential{}, fmt.Errorf("failed to unmarshal credential: %w", err)
+ }
+
+ return credential, nil
+}
+
+func (c *UserWebauthnCredential) GetCredentialId() string {
+ decodeString, _ := base64.StdEncoding.DecodeString(c.CredentialIdentifier)
+
+ return string(decodeString)
+}
+
+// endregion webauthn