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

@ -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)

View File

@ -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

View File

@ -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.

View File

@ -24,7 +24,7 @@
<div id="toasts"></div>
<!-- main application -->
<div id="app"></div>
<div id="app" class="d-flex flex-column flex-grow-1"></div>
<!-- vue teleport will add modals and dialogs here -->
<div id="modals"></div>

View File

@ -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",

View File

@ -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",

View File

@ -140,6 +140,7 @@ const currentYear = ref(new Date().getFullYear())
</div>
</div>
</div>
</footer></template>
</footer>
</template>
<style></style>

View File

@ -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": {

View File

@ -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": {

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",

View File

@ -1,6 +1,6 @@
<script setup>
import {computed, ref} from "vue";
import {computed, onMounted, ref} from "vue";
import {authStore} from "@/stores/auth";
import router from '../router/index.js'
import {notify} from "@kyvg/vue3-notification";
@ -17,6 +17,11 @@ const usernameInvalid = computed(() => username.value === "")
const passwordInvalid = computed(() => password.value === "")
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
onMounted(async () => {
await settings.LoadSettings()
})
const login = async function () {
console.log("Performing login for user:", username.value);
loggingIn.value = true;
@ -28,7 +33,34 @@ const login = async function () {
type: 'success',
});
loggingIn.value = false;
settings.LoadSettings(); // only logs errors, does not throw
settings.LoadSettings(); // reload full settings
router.push(auth.ReturnUrl);
})
.catch(error => {
notify({
title: "Login failed!",
text: "Authentication failed!",
type: 'error',
});
//loggingIn.value = false;
// delay the user from logging in for a short amount of time
setTimeout(() => loggingIn.value = false, 1000);
});
}
const loginWebAuthn = async function () {
console.log("Performing webauthn login");
loggingIn.value = true;
auth.LoginWebAuthn()
.then(uid => {
notify({
title: "Logged in",
text: "Authentication succeeded!",
type: 'success',
});
loggingIn.value = false;
settings.LoadSettings(); // reload full settings
router.push(auth.ReturnUrl);
})
.catch(error => {
@ -85,17 +117,25 @@ const externalLogin = function (provider) {
</div>
</div>
<div class="row mt-5 d-flex">
<div :class="{'col-lg-4':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
<button :disabled="disableLoginBtn" class="btn btn-primary flex-fill" type="submit" @click.prevent="login">
<div class="row mt-5 mb-2">
<div class="col-lg-4">
<button :disabled="disableLoginBtn" class="btn btn-primary" type="submit" @click.prevent="login">
{{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
</button>
</div>
<div :class="{'col-lg-8':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
<div class="col-lg-8 mb-2 text-end">
<button v-if="settings.Setting('WebAuthnEnabled')" class="btn btn-primary" type="submit" @click.prevent="loginWebAuthn">
{{ $t('login.button-webauthn') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
</button>
</div>
</div>
<div class="row mt-5 d-flex">
<div class="col-lg-12 d-flex mb-2">
<!-- OpenIdConnect / OAUTH providers -->
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
</div>
</div>

View File

@ -1,5 +1,5 @@
<script setup>
import { onMounted } from "vue";
import {onMounted, ref} from "vue";
import { profileStore } from "@/stores/profile";
import { settingsStore } from "@/stores/settings";
import { authStore } from "../stores/auth";
@ -10,8 +10,30 @@ const auth = authStore()
onMounted(async () => {
await profile.LoadUser()
await auth.LoadWebAuthnCredentials()
})
const selectedCredential = ref({})
function enableRename(credential) {
credential.renameMode = true;
credential.tempName = credential.Name; // Store the original name
}
function cancelRename(credential) {
credential.renameMode = false;
credential.tempName = null; // Discard changes
}
async function saveRename(credential) {
try {
await auth.RenameWebAuthnCredential({ ...credential, Name: credential.tempName });
credential.Name = credential.tempName; // Update the name
credential.renameMode = false;
} catch (error) {
console.error("Failed to rename credential:", error);
}
}
</script>
<template>
@ -69,4 +91,86 @@ onMounted(async () => {
</button>
</div>
</div>
<div class="bg-light p-5 mt-5" v-if="settings.Setting('WebAuthnEnabled')">
<h2 class="display-7">{{ $t('settings.webauthn.headline') }}</h2>
<p class="lead">{{ $t('settings.webauthn.abstract') }}</p>
<hr class="my-4">
<p v-if="auth.IsWebAuthnEnabled">{{ $t('settings.webauthn.active-description') }}</p>
<p v-else>{{ $t('settings.webauthn.inactive-description') }}</p>
<div class="row">
<div class="col-6">
<button class="input-group-text btn btn-primary" :title="$t('settings.webauthn.button-register-text')" @click.prevent="auth.RegisterWebAuthn" :disabled="auth.isFetching">
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.webauthn.button-register-title') }}
</button>
</div>
</div>
<div v-if="auth.WebAuthnCredentials.length > 0" class="mt-4">
<h3>{{ $t('settings.webauthn.credentials-list') }}</h3>
<table class="table table-striped">
<thead>
<tr>
<th style="width: 50%">{{ $t('settings.webauthn.table.name') }}</th>
<th style="width: 20%">{{ $t('settings.webauthn.table.created') }}</th>
<th style="width: 30%">{{ $t('settings.webauthn.table.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="credential in auth.webAuthnCredentials" :key="credential.ID">
<td class="align-middle">
<div v-if="credential.renameMode">
<input v-model="credential.tempName" class="form-control" type="text" />
</div>
<div v-else>
{{ credential.Name }}
</div>
</td>
<td class="align-middle">
{{ credential.CreatedAt }}
</td>
<td class="align-middle text-center">
<div v-if="credential.renameMode">
<button class="btn btn-success me-1" :title="$t('settings.webauthn.button-save-text')" @click.prevent="saveRename(credential)" :disabled="auth.isFetching">
{{ $t('settings.webauthn.button-save-title') }}
</button>
<button class="btn btn-secondary" :title="$t('settings.webauthn.button-cancel-text')" @click.prevent="cancelRename(credential)">
{{ $t('settings.webauthn.button-cancel-title') }}
</button>
</div>
<div v-else>
<button class="btn btn-secondary me-1" :title="$t('settings.webauthn.button-rename-text')" @click.prevent="enableRename(credential)">
{{ $t('settings.webauthn.button-rename-title') }}
</button>
<button class="btn btn-danger" :title="$t('settings.webauthn.button-delete-text')" data-bs-toggle="modal" data-bs-target="#webAuthnDeleteModal" :disabled="auth.isFetching" @click="selectedCredential=credential">
{{ $t('settings.webauthn.button-delete-title') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal fade" id="webAuthnDeleteModal" tabindex="-1" aria-labelledby="webAuthnDeleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="webAuthnDeleteModalLabel">{{ $t('settings.webauthn.modal-delete.headline') }}</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" :aria-label="$t('settings.webauthn.modal-delete.button-cancel')"></button>
</div>
<div class="modal-body">
<h5 class="mb-3">{{ selectedCredential.Name }} <small class="text-body-secondary">({{ $t('settings.webauthn.modal-delete.created') }} {{ selectedCredential.CreatedAt }})</small></h5>
<p class="mb-0">{{ $t('settings.webauthn.modal-delete.abstract') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ $t('settings.webauthn.modal-delete.button-cancel') }}</button>
<button type="button" class="btn btn-danger" id="confirmWebAuthnDelete" @click="auth.DeleteWebAuthnCredential(selectedCredential.ID)" :disabled="auth.isFetching" data-bs-dismiss="modal">{{ $t('settings.webauthn.modal-delete.button-delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</template>

7
go.mod
View File

@ -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

16
go.sum
View File

@ -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=

View File

@ -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
}

View File

@ -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"
}
}
}
}
}

View File

@ -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

View File

@ -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))
}
}

View File

@ -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,
})
}
}
}

View File

@ -31,6 +31,8 @@ type SessionData struct {
OauthProvider string
OauthReturnTo string
WebAuthnData string
CsrfToken string
}

View File

@ -10,4 +10,5 @@ type Settings struct {
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
SelfProvisioning bool `json:"SelfProvisioning"`
ApiAdminOnly bool `json:"ApiAdminOnly"`
WebAuthnEnabled bool `json:"WebAuthnEnabled"`
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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"`
}

View File

@ -164,6 +164,8 @@ func defaultConfig() *Config {
cfg.Webhook.Authentication = ""
cfg.Webhook.Timeout = 10 * time.Second
cfg.Auth.WebAuthn.Enabled = true
return cfg
}

View File

@ -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