mirror of
https://github.com/h44z/wg-portal.git
synced 2025-06-27 16:57:01 +00:00
add webauthn (passkey) support
This commit is contained in:
parent
6a96925be7
commit
1394be2341
@ -88,6 +88,9 @@ func main() {
|
|||||||
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
|
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
|
|
||||||
|
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
|
||||||
|
internal.AssertNoError(err)
|
||||||
|
|
||||||
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
|
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
|
||||||
internal.AssertNoError(err)
|
internal.AssertNoError(err)
|
||||||
wireGuardManager.StartBackgroundJobs(ctx)
|
wireGuardManager.StartBackgroundJobs(ctx)
|
||||||
@ -124,7 +127,8 @@ func main() {
|
|||||||
apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager)
|
apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager)
|
||||||
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
|
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)
|
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
|
||||||
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
|
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
|
||||||
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
|
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
|
||||||
|
@ -32,6 +32,10 @@ database:
|
|||||||
type: sqlite
|
type: sqlite
|
||||||
dsn: data/sqlite.db
|
dsn: data/sqlite.db
|
||||||
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
|
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
|
||||||
|
|
||||||
|
auth:
|
||||||
|
webauthn:
|
||||||
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
## LDAP Authentication and Synchronization
|
## LDAP Authentication and Synchronization
|
||||||
|
@ -72,6 +72,8 @@ auth:
|
|||||||
oidc: []
|
oidc: []
|
||||||
oauth: []
|
oauth: []
|
||||||
ldap: []
|
ldap: []
|
||||||
|
webauthn:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
web:
|
web:
|
||||||
listening_address: :8888
|
listening_address: :8888
|
||||||
@ -120,6 +122,7 @@ More advanced options are found in the subsequent `Advanced` section.
|
|||||||
### `admin_password`
|
### `admin_password`
|
||||||
- **Default:** `wgportal`
|
- **Default:** `wgportal`
|
||||||
- **Description:** The administrator password. The default password of `wgportal` should be changed immediately.
|
- **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`
|
### `admin_api_token`
|
||||||
- **Default:** *(empty)*
|
- **Default:** *(empty)*
|
||||||
@ -334,7 +337,7 @@ Options for configuring email notifications or sending peer configurations via e
|
|||||||
|
|
||||||
## Auth
|
## 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.
|
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
|
## Web
|
||||||
|
|
||||||
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
|
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
<div id="toasts"></div>
|
<div id="toasts"></div>
|
||||||
|
|
||||||
<!-- main application -->
|
<!-- 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 -->
|
<!-- vue teleport will add modals and dialogs here -->
|
||||||
<div id="modals"></div>
|
<div id="modals"></div>
|
||||||
|
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@kyvg/vue3-notification": "^3.4.1",
|
"@kyvg/vue3-notification": "^3.4.1",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
||||||
"bootstrap": "^5.3.5",
|
"bootstrap": "^5.3.5",
|
||||||
"bootswatch": "^5.3.5",
|
"bootswatch": "^5.3.5",
|
||||||
@ -863,6 +864,12 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@kyvg/vue3-notification": "^3.4.1",
|
"@kyvg/vue3-notification": "^3.4.1",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
"@vojtechlanka/vue-tags-input": "^3.1.1",
|
||||||
"bootstrap": "^5.3.5",
|
"bootstrap": "^5.3.5",
|
||||||
"bootswatch": "^5.3.5",
|
"bootswatch": "^5.3.5",
|
||||||
|
@ -140,6 +140,7 @@ const currentYear = ref(new Date().getFullYear())
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer></template>
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style></style>
|
<style></style>
|
||||||
|
@ -29,7 +29,8 @@
|
|||||||
"label": "Passwort",
|
"label": "Passwort",
|
||||||
"placeholder": "Bitte geben Sie Ihr Passwort ein"
|
"placeholder": "Bitte geben Sie Ihr Passwort ein"
|
||||||
},
|
},
|
||||||
"button": "Anmelden"
|
"button": "Anmelden",
|
||||||
|
"button-webauthn": "Passkey verwenden"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@ -188,6 +189,35 @@
|
|||||||
"button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.",
|
"button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.",
|
||||||
"button-enable-text": "API aktivieren",
|
"button-enable-text": "API aktivieren",
|
||||||
"api-link": "API Dokumentation"
|
"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": {
|
"audit": {
|
||||||
|
@ -29,7 +29,8 @@
|
|||||||
"label": "Password",
|
"label": "Password",
|
||||||
"placeholder": "Please enter your password"
|
"placeholder": "Please enter your password"
|
||||||
},
|
},
|
||||||
"button": "Sign in"
|
"button": "Sign in",
|
||||||
|
"button-webauthn": "Use Passkey"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@ -188,6 +189,35 @@
|
|||||||
"button-enable-title": "Enable API, this will generate a new token.",
|
"button-enable-title": "Enable API, this will generate a new token.",
|
||||||
"button-enable-text": "Enable API",
|
"button-enable-text": "Enable API",
|
||||||
"api-link": "API Documentation"
|
"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": {
|
"audit": {
|
||||||
|
@ -3,13 +3,17 @@ import { defineStore } from 'pinia'
|
|||||||
import { notify } from "@kyvg/vue3-notification";
|
import { notify } from "@kyvg/vue3-notification";
|
||||||
import { apiWrapper } from '@/helpers/fetch-wrapper'
|
import { apiWrapper } from '@/helpers/fetch-wrapper'
|
||||||
import router from '../router'
|
import router from '../router'
|
||||||
|
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import {base64_url_encode} from "@/helpers/encoding";
|
||||||
|
|
||||||
export const authStore = defineStore('auth',{
|
export const authStore = defineStore('auth',{
|
||||||
state: () => ({
|
state: () => ({
|
||||||
// initialize state from local storage to enable user to stay logged in
|
// initialize state from local storage to enable user to stay logged in
|
||||||
user: JSON.parse(localStorage.getItem('user')),
|
user: JSON.parse(localStorage.getItem('user')),
|
||||||
providers: [],
|
providers: [],
|
||||||
returnUrl: localStorage.getItem('returnUrl')
|
returnUrl: localStorage.getItem('returnUrl'),
|
||||||
|
webAuthnCredentials: [],
|
||||||
|
fetching: false,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
|
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
|
||||||
@ -18,6 +22,14 @@ export const authStore = defineStore('auth',{
|
|||||||
IsAuthenticated: (state) => state.user != null,
|
IsAuthenticated: (state) => state.user != null,
|
||||||
IsAdmin: (state) => state.user?.IsAdmin || false,
|
IsAdmin: (state) => state.user?.IsAdmin || false,
|
||||||
ReturnUrl: (state) => state.returnUrl || '/',
|
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: {
|
actions: {
|
||||||
SetReturnUrl(link) {
|
SetReturnUrl(link) {
|
||||||
@ -60,6 +72,23 @@ export const authStore = defineStore('auth',{
|
|||||||
return Promise.reject(err)
|
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.
|
// Login returns promise that might have been rejected if the login attempt was not successful.
|
||||||
async Login(username, password) {
|
async Login(username, password) {
|
||||||
return apiWrapper.post(`/auth/login`, { username, password })
|
return apiWrapper.post(`/auth/login`, { username, password })
|
||||||
@ -93,6 +122,157 @@ export const authStore = defineStore('auth',{
|
|||||||
|
|
||||||
await router.push('/login')
|
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
|
// -- internal setters
|
||||||
setUserInfo(userInfo) {
|
setUserInfo(userInfo) {
|
||||||
// store user details and jwt in local storage to keep user logged in between page refreshes
|
// 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')
|
localStorage.removeItem('user')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setWebAuthnCredentials(credentials) {
|
||||||
|
this.fetching = false
|
||||||
|
this.webAuthnCredentials = credentials
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -129,7 +129,7 @@ export const profileStore = defineStore('profile', {
|
|||||||
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/enable`)
|
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/enable`)
|
||||||
.then(this.setUser)
|
.then(this.setUser)
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
this.setPeers([])
|
this.fetching = false
|
||||||
console.log("Failed to activate API for ", currentUser, ": ", error)
|
console.log("Failed to activate API for ", currentUser, ": ", error)
|
||||||
notify({
|
notify({
|
||||||
title: "Backend Connection Failure",
|
title: "Backend Connection Failure",
|
||||||
@ -143,7 +143,7 @@ export const profileStore = defineStore('profile', {
|
|||||||
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/disable`)
|
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/api/disable`)
|
||||||
.then(this.setUser)
|
.then(this.setUser)
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
this.setPeers([])
|
this.fetching = false
|
||||||
console.log("Failed to deactivate API for ", currentUser, ": ", error)
|
console.log("Failed to deactivate API for ", currentUser, ": ", error)
|
||||||
notify({
|
notify({
|
||||||
title: "Backend Connection Failure",
|
title: "Backend Connection Failure",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
import {computed, ref} from "vue";
|
import {computed, onMounted, ref} from "vue";
|
||||||
import {authStore} from "@/stores/auth";
|
import {authStore} from "@/stores/auth";
|
||||||
import router from '../router/index.js'
|
import router from '../router/index.js'
|
||||||
import {notify} from "@kyvg/vue3-notification";
|
import {notify} from "@kyvg/vue3-notification";
|
||||||
@ -17,6 +17,11 @@ const usernameInvalid = computed(() => username.value === "")
|
|||||||
const passwordInvalid = computed(() => password.value === "")
|
const passwordInvalid = computed(() => password.value === "")
|
||||||
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
|
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await settings.LoadSettings()
|
||||||
|
})
|
||||||
|
|
||||||
const login = async function () {
|
const login = async function () {
|
||||||
console.log("Performing login for user:", username.value);
|
console.log("Performing login for user:", username.value);
|
||||||
loggingIn.value = true;
|
loggingIn.value = true;
|
||||||
@ -28,7 +33,34 @@ const login = async function () {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
loggingIn.value = false;
|
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);
|
router.push(auth.ReturnUrl);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@ -85,17 +117,25 @@ const externalLogin = function (provider) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mt-5 d-flex">
|
<div class="row mt-5 mb-2">
|
||||||
<div :class="{'col-lg-4':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
|
<div class="col-lg-4">
|
||||||
<button :disabled="disableLoginBtn" class="btn btn-primary flex-fill" type="submit" @click.prevent="login">
|
<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>
|
{{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- OpenIdConnect / OAUTH providers -->
|
||||||
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
|
<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"
|
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
|
||||||
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
|
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from "vue";
|
import {onMounted, ref} from "vue";
|
||||||
import { profileStore } from "@/stores/profile";
|
import { profileStore } from "@/stores/profile";
|
||||||
import { settingsStore } from "@/stores/settings";
|
import { settingsStore } from "@/stores/settings";
|
||||||
import { authStore } from "../stores/auth";
|
import { authStore } from "../stores/auth";
|
||||||
@ -10,8 +10,30 @@ const auth = authStore()
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await profile.LoadUser()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -69,4 +91,86 @@ onMounted(async () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
7
go.mod
7
go.mod
@ -10,6 +10,7 @@ require (
|
|||||||
github.com/go-ldap/ldap/v3 v3.4.11
|
github.com/go-ldap/ldap/v3 v3.4.11
|
||||||
github.com/go-pkgz/routegroup v1.4.1
|
github.com/go-pkgz/routegroup v1.4.1
|
||||||
github.com/go-playground/validator/v10 v10.26.0
|
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/google/uuid v1.6.0
|
||||||
github.com/prometheus-community/pro-bing v0.7.0
|
github.com/prometheus-community/pro-bing v0.7.0
|
||||||
github.com/prometheus/client_golang v1.22.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/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.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/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // 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-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||||
github.com/go-test/deep v1.1.1 // 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/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.4 // 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/netlink v1.7.2 // indirect
|
||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
github.com/microsoft/go-mssqldb v1.8.0 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // 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
|
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
golang.org/x/net v0.39.0 // indirect
|
||||||
|
16
go.sum
16
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
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-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 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
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.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.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
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=
|
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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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=
|
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/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 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
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/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/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
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.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
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 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
|
||||||
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
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=
|
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||||
|
@ -220,6 +220,8 @@ func (r *SqlRepo) preCheck() error {
|
|||||||
func (r *SqlRepo) migrate() error {
|
func (r *SqlRepo) migrate() error {
|
||||||
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
|
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", "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: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
|
||||||
slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{}))
|
slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{}))
|
||||||
slog.Debug("running migration: peer status", "result", r.db.AutoMigrate(&domain.PeerStatus{}))
|
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) {
|
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
|
||||||
var user domain.User
|
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) {
|
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, domain.ErrNotFound
|
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) {
|
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
var users []domain.User
|
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) {
|
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, domain.ErrNotFound
|
return nil, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
@ -785,11 +787,26 @@ func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Use
|
|||||||
return &user, nil
|
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.
|
// GetAllUsers returns all users.
|
||||||
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
||||||
var users []domain.User
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -808,6 +825,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
|
|||||||
Or("firstname LIKE ?", searchValue).
|
Or("firstname LIKE ?", searchValue).
|
||||||
Or("lastname LIKE ?", searchValue).
|
Or("lastname LIKE ?", searchValue).
|
||||||
Or("email LIKE ?", searchValue).
|
Or("email LIKE ?", searchValue).
|
||||||
|
Preload("WebAuthnCredentialList").
|
||||||
Find(&users).Error
|
Find(&users).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -853,7 +871,7 @@ func (r *SqlRepo) SaveUser(
|
|||||||
|
|
||||||
// DeleteUser deletes the user with the given id.
|
// DeleteUser deletes the user with the given id.
|
||||||
func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -897,6 +915,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
|
|||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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": {
|
"/auth/{provider}/callback": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
@ -2093,6 +2239,9 @@
|
|||||||
},
|
},
|
||||||
"SelfProvisioning": {
|
"SelfProvisioning": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"WebAuthnEnabled": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -2161,6 +2310,28 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"model.WebAuthnCredentialRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"Name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model.WebAuthnCredentialResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"CreatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ID": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"Name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -387,6 +387,8 @@ definitions:
|
|||||||
type: boolean
|
type: boolean
|
||||||
SelfProvisioning:
|
SelfProvisioning:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
WebAuthnEnabled:
|
||||||
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
model.User:
|
model.User:
|
||||||
properties:
|
properties:
|
||||||
@ -433,6 +435,20 @@ definitions:
|
|||||||
Source:
|
Source:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
info:
|
||||||
contact:
|
contact:
|
||||||
name: WireGuard Portal Developers
|
name: WireGuard Portal Developers
|
||||||
@ -548,6 +564,102 @@ paths:
|
|||||||
summary: Get information about the currently logged-in user.
|
summary: Get information about the currently logged-in user.
|
||||||
tags:
|
tags:
|
||||||
- Authentication
|
- 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:
|
/config/frontend.js:
|
||||||
get:
|
get:
|
||||||
operationId: config_handleConfigJsGet
|
operationId: config_handleConfigJsGet
|
||||||
|
@ -28,12 +28,54 @@ type AuthenticationService interface {
|
|||||||
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
|
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 {
|
type AuthEndpoint struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
authService AuthenticationService
|
authService AuthenticationService
|
||||||
authenticator Authenticator
|
authenticator Authenticator
|
||||||
session Session
|
session Session
|
||||||
validate Validator
|
validate Validator
|
||||||
|
webAuthn WebAuthnService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthEndpoint(
|
func NewAuthEndpoint(
|
||||||
@ -42,6 +84,7 @@ func NewAuthEndpoint(
|
|||||||
session Session,
|
session Session,
|
||||||
validator Validator,
|
validator Validator,
|
||||||
authService AuthenticationService,
|
authService AuthenticationService,
|
||||||
|
webAuthn WebAuthnService,
|
||||||
) AuthEndpoint {
|
) AuthEndpoint {
|
||||||
return AuthEndpoint{
|
return AuthEndpoint{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@ -49,6 +92,7 @@ func NewAuthEndpoint(
|
|||||||
authenticator: authenticator,
|
authenticator: authenticator,
|
||||||
session: session,
|
session: session,
|
||||||
validate: validator,
|
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}/init", e.handleOauthInitiateGet())
|
||||||
apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet())
|
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.HandleFunc("POST /login", e.handleLoginPost())
|
||||||
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
|
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
|
||||||
}
|
}
|
||||||
@ -389,3 +446,237 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
|
|||||||
|
|
||||||
return true
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/h44z/wg-portal/internal/app/api/core/respond"
|
"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/app/api/v0/model"
|
||||||
"github.com/h44z/wg-portal/internal/config"
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed frontend_config.js.gotpl
|
//go:embed frontend_config.js.gotpl
|
||||||
@ -46,7 +47,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
apiGroup := g.Mount("/config")
|
apiGroup := g.Mount("/config")
|
||||||
|
|
||||||
apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet())
|
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.
|
// handleConfigJsGet returns a gorm Handler function.
|
||||||
@ -93,11 +94,21 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
|
|||||||
// @Router /config/settings [get]
|
// @Router /config/settings [get]
|
||||||
func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
|
func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
respond.JSON(w, http.StatusOK, model.Settings{
|
sessionUser := domain.GetUserInfo(r.Context())
|
||||||
MailLinkOnly: e.cfg.Mail.LinkOnly,
|
|
||||||
PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
|
// For anonymous users, we return the settings object with minimal information
|
||||||
SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
|
if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" {
|
||||||
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,6 +31,8 @@ type SessionData struct {
|
|||||||
OauthProvider string
|
OauthProvider string
|
||||||
OauthReturnTo string
|
OauthReturnTo string
|
||||||
|
|
||||||
|
WebAuthnData string
|
||||||
|
|
||||||
CsrfToken string
|
CsrfToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,4 +10,5 @@ type Settings struct {
|
|||||||
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
|
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
|
||||||
SelfProvisioning bool `json:"SelfProvisioning"`
|
SelfProvisioning bool `json:"SelfProvisioning"`
|
||||||
ApiAdminOnly bool `json:"ApiAdminOnly"`
|
ApiAdminOnly bool `json:"ApiAdminOnly"`
|
||||||
|
WebAuthnEnabled bool `json:"WebAuthnEnabled"`
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "github.com/h44z/wg-portal/internal/domain"
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
type LoginProviderInfo struct {
|
type LoginProviderInfo struct {
|
||||||
Identifier string `json:"Identifier" example:"google"`
|
Identifier string `json:"Identifier" example:"google"`
|
||||||
@ -39,3 +44,32 @@ type OauthInitiationResponse struct {
|
|||||||
RedirectUrl string
|
RedirectUrl string
|
||||||
State 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
|
||||||
|
}
|
||||||
|
301
internal/app/auth/webauthn.go
Normal file
301
internal/app/auth/webauthn.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,8 @@ type UserDatabaseRepo interface {
|
|||||||
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
|
||||||
// GetUserByEmail returns the user with the given email address.
|
// GetUserByEmail returns the user with the given email address.
|
||||||
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
|
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 returns all users.
|
||||||
GetAllUsers(ctx context.Context) ([]domain.User, error)
|
GetAllUsers(ctx context.Context) ([]domain.User, error)
|
||||||
// FindUsers returns all users matching the search string.
|
// 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
|
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.
|
// GetAllUsers returns all users.
|
||||||
func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
|
||||||
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
|
||||||
|
@ -16,6 +16,8 @@ type Auth struct {
|
|||||||
OAuth []OAuthProvider `yaml:"oauth"`
|
OAuth []OAuthProvider `yaml:"oauth"`
|
||||||
// Ldap contains a list of LDAP providers.
|
// Ldap contains a list of LDAP providers.
|
||||||
Ldap []LdapProvider `yaml:"ldap"`
|
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.
|
// 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.
|
// 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"`
|
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"`
|
||||||
|
}
|
||||||
|
@ -164,6 +164,8 @@ func defaultConfig() *Config {
|
|||||||
cfg.Webhook.Authentication = ""
|
cfg.Webhook.Authentication = ""
|
||||||
cfg.Webhook.Timeout = 10 * time.Second
|
cfg.Webhook.Timeout = 10 * time.Second
|
||||||
|
|
||||||
|
cfg.Auth.WebAuthn.Enabled = true
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,9 +2,16 @@ package domain
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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)
|
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
|
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
|
// API token for REST API access
|
||||||
ApiToken string `form:"api_token" binding:"omitempty"`
|
ApiToken string `form:"api_token" binding:"omitempty"`
|
||||||
ApiTokenCreated *time.Time
|
ApiTokenCreated *time.Time
|
||||||
@ -157,3 +168,148 @@ func (u *User) CopyCalculatedAttributes(src *User) {
|
|||||||
u.BaseModel = src.BaseModel
|
u.BaseModel = src.BaseModel
|
||||||
u.LinkedPeerCount = src.LinkedPeerCount
|
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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user