From 1394be2341562c234e29d0915b275668518ace7a Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Mon, 12 May 2025 22:53:43 +0200 Subject: [PATCH] add webauthn (passkey) support --- cmd/wg-portal/main.go | 6 +- docs/documentation/configuration/examples.md | 4 + docs/documentation/configuration/overview.md | 15 +- frontend/index.html | 2 +- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/App.vue | 3 +- frontend/src/lang/translations/de.json | 32 +- frontend/src/lang/translations/en.json | 32 +- frontend/src/stores/auth.js | 186 ++++++++++- frontend/src/stores/profile.js | 4 +- frontend/src/views/LoginView.vue | 58 +++- frontend/src/views/SettingsView.vue | 106 +++++- go.mod | 7 + go.sum | 16 +- internal/adapters/database.go | 31 +- .../app/api/core/assets/doc/v0_swagger.json | 171 ++++++++++ .../app/api/core/assets/doc/v0_swagger.yaml | 112 +++++++ .../v0/handlers/endpoint_authentication.go | 291 +++++++++++++++++ .../app/api/v0/handlers/endpoint_config.go | 25 +- internal/app/api/v0/handlers/web_session.go | 2 + internal/app/api/v0/model/models.go | 1 + .../app/api/v0/model/models_authentication.go | 36 ++- internal/app/auth/webauthn.go | 301 ++++++++++++++++++ internal/app/users/user_manager.go | 21 ++ internal/config/auth.go | 8 + internal/config/config.go | 2 + internal/domain/user.go | 156 +++++++++ 28 files changed, 1603 insertions(+), 33 deletions(-) create mode 100644 internal/app/auth/webauthn.go 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 @@ 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