Compare commits

..

15 Commits

Author SHA1 Message Date
h44z
dd28a8dddf
allow to hide login form (#459) (#470)
Some checks are pending
Docker / Build and Push (push) Waiting to run
Docker / release (push) Blocked by required conditions
github-pages / deploy (push) Waiting to run
use the `hide_login_form` parameter in the `auth` settings to configure this feature
2025-06-27 13:50:38 +02:00
dependabot[bot]
f994700caf
chore(deps): bump alpine from 3.19 to 3.22 (#465)
Bumps alpine from 3.19 to 3.22.

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.22'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-27 12:38:18 +02:00
h44z
be29abd29a
add webhook event for peer state change (#444) (#468)
* add webhook event for peer state change (#444)

new event types: connect and disconnect

example payload:

```json
{
  "event": "connect",
  "entity": "peer",
  "identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
  "payload": {
    "PeerId": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
    "IsConnected": true,
    "IsPingable": false,
    "LastPing": null,
    "BytesReceived": 1860,
    "BytesTransmitted": 10824,
    "LastHandshake": "2025-06-26T23:04:33.325216659+02:00",
    "Endpoint": "10.55.66.77:33874",
    "LastSessionStart": "2025-06-26T22:50:40.10221606+02:00"
  }
}
```

* add webhook docs (#444)
2025-06-27 12:37:10 +02:00
h44z
94785c10ec
use website title in mail templates (#448) (#466)
* use website title in mail templates (#448)

* change button font color to white (#448)
2025-06-27 11:45:44 +02:00
HPPinata
3a732fd3e5
add docker to dependabot (#463)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2025-06-26 20:20:15 +02:00
S.J. Louw
f0be66aea4
Option to limit peer count that a normal user can create (#457) 2025-06-26 20:17:45 +02:00
dependabot[bot]
cbf8c5bca9
chore(deps): bump the gorm group across 1 directory with 4 updates (#453)
Bumps the gorm group with 3 updates in the / directory: [gorm.io/driver/mysql](https://github.com/go-gorm/mysql), [gorm.io/driver/postgres](https://github.com/go-gorm/postgres) and [gorm.io/driver/sqlserver](https://github.com/go-gorm/sqlserver).


Updates `gorm.io/driver/mysql` from 1.5.7 to 1.6.0
- [Commits](https://github.com/go-gorm/mysql/compare/v1.5.7...v1.6.0)

Updates `gorm.io/driver/postgres` from 1.5.11 to 1.6.0
- [Commits](https://github.com/go-gorm/postgres/compare/v1.5.11...v1.6.0)

Updates `gorm.io/driver/sqlserver` from 1.5.4 to 1.6.0
- [Commits](https://github.com/go-gorm/sqlserver/compare/v1.5.4...v1.6.0)

Updates `gorm.io/gorm` from 1.26.1 to 1.30.0
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.26.1...v1.30.0)

---
updated-dependencies:
- dependency-name: gorm.io/driver/mysql
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gorm
- dependency-name: gorm.io/driver/postgres
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gorm
- dependency-name: gorm.io/driver/sqlserver
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gorm
- dependency-name: gorm.io/gorm
  dependency-version: 1.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gorm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-26 20:17:12 +02:00
dependabot[bot]
b6bfa1f6de
chore(deps): bump golang.org/x/crypto in the golang group (#454)
Bumps the golang group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.38.0 to 0.39.0
- [Commits](https://github.com/golang/crypto/compare/v0.38.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-26 20:10:32 +02:00
dependabot[bot]
0c8d6223ce
chore(deps): bump github.com/go-webauthn/webauthn from 0.12.3 to 0.13.0 (#440)
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.12.3 to 0.13.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.12.3...v0.13.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-26 20:09:49 +02:00
Christoph Haas
e3b65ca337
improve logging of OAuth login issues, decrease auth-code exchange timeout (#451)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-06-10 17:51:45 +02:00
Christoph Haas
61d8aa6589
fix self-provisioned peer-generation (#452) 2025-06-08 11:17:04 +02:00
Christoph Haas
7fd2bbad02
Merge branch 'passkey_support' 2025-05-17 19:22:44 +02:00
Christoph Haas
75a5f3d815
add/improve documentation 2025-05-16 14:58:05 +02:00
Christoph Haas
e9005b1b90
add minimum password length check 2025-05-16 09:55:35 +02:00
Christoph Haas
1394be2341 add webauthn (passkey) support 2025-05-12 22:53:43 +02:00
56 changed files with 2297 additions and 177 deletions

View File

@ -28,3 +28,8 @@ updates:
patch:
update-types:
- patch
- package-ecosystem: "docker"
directory: /
schedule:
interval: weekly

View File

@ -50,7 +50,7 @@ COPY --from=builder /build/dist/wg-portal /
######
# Final image
######
FROM alpine:3.19
FROM alpine:3.22
# Install OS-level dependencies
RUN apk add --no-cache bash curl iptables nftables openresolv wireguard-tools
# Setup timezone

View File

@ -27,7 +27,7 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL, or Pos
* Sends email to the client with QR-code and client config
* Enable / Disable clients seamlessly
* Generation of wg-quick configuration file (`wgX.conf`) if required
* User authentication (database, OAuth, or LDAP)
* User authentication (database, OAuth, or LDAP), Passkey support
* IPv6 ready
* Docker ready
* Can be used with existing WireGuard setups

View File

@ -88,6 +88,9 @@ func main() {
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager)
internal.AssertNoError(err)
webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager)
internal.AssertNoError(err)
wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database)
internal.AssertNoError(err)
wireGuardManager.StartBackgroundJobs(ctx)
@ -124,7 +127,8 @@ func main() {
apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager)
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator)
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator,
webAuthn)
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -32,6 +32,10 @@ database:
type: sqlite
dsn: data/sqlite.db
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
auth:
webauthn:
enabled: true
```
## LDAP Authentication and Synchronization

View File

@ -14,7 +14,7 @@ Configuration examples are available on the [Examples](./examples.md) page.
```yaml
core:
admin_user: admin@wgportal.local
admin_password: wgportal
admin_password: wgportal-default
admin_api_token: ""
editable_keys: true
create_default_peer: false
@ -38,6 +38,7 @@ advanced:
rule_prio_offset: 20000
route_table_offset: 20000
api_admin_only: true
limit_additional_user_peers: 0
database:
debug: false
@ -72,6 +73,10 @@ auth:
oidc: []
oauth: []
ldap: []
webauthn:
enabled: true
min_password_length: 16
hide_login_form: false
web:
listening_address: :8888
@ -118,8 +123,9 @@ More advanced options are found in the subsequent `Advanced` section.
- **Description:** The administrator user. This user will be created as a default admin if it does not yet exist.
### `admin_password`
- **Default:** `wgportal`
- **Description:** The administrator password. The default password of `wgportal` should be changed immediately.
- **Default:** `wgportal-default`
- **Description:** The administrator password. The default password should be changed immediately!
- **Important:** The password should be strong and secure. The minimum password length is specified in [auth.min_password_length](#min_password_length). By default, it is 16 characters.
### `admin_api_token`
- **Default:** *(empty)*
@ -211,6 +217,10 @@ Additional or more specialized configuration options for logging and interface c
- **Default:** `true`
- **Description:** If `true`, the public REST API is accessible only to admin users. The API docs live at [`/api/v1/doc.html`](../rest-api/api-doc.md).
### `limit_additional_user_peers`
- **Default:** `0`
- **Description:** Limit additional peers a normal user can create. `0` means unlimited.
---
## Database
@ -334,9 +344,23 @@ Options for configuring email notifications or sending peer configurations via e
## Auth
WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), and **LDAP** (`ldap`).
WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), **Passkeys** (`webauthn`) and **LDAP** (`ldap`).
Each can have multiple providers configured. Below are the relevant keys.
Some core authentication options are shared across all providers, while others are specific to each provider type.
### `min_password_length`
- **Default:** `16`
- **Description:** Minimum password length for local authentication. This is not enforced for LDAP authentication.
The default admin password strength is also enforced by this setting.
- **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.
### `hide_login_form`
- **Default:** `false`
- **Description:** If `true`, the login form is hidden and only the OIDC, OAuth, LDAP, or WebAuthn providers are shown. This is useful if you want to enforce a specific authentication method.
If no social login providers are configured, the login form is always shown, regardless of this setting.
- **Important:** You can still access the login form by adding the `?all` query parameter to the login URL (e.g. https://wg.portal/#/login?all).
---
### OIDC
@ -540,6 +564,8 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
```text
(&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2))
```
- **Important**: The `login_filter` must always be a valid LDAP filter. It should at most return one user.
If the filter returns multiple or no users, the login will fail.
#### `admin_group`
- **Default:** *(empty)*
@ -580,6 +606,16 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
---
### WebAuthn (Passkeys)
The `webauthn` section contains configuration options for WebAuthn authentication (passkeys).
#### `enabled`
- **Default:** `true`
- **Description:** If `true`, Passkey authentication is enabled. If `false`, WebAuthn is disabled.
Users are encouraged to use Passkeys for secure authentication instead of passwords.
If a passkey is registered, the password login is still available as a fallback. Ensure that the password is strong and secure.
## Web
The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection.
@ -640,7 +676,7 @@ The webhook section allows you to configure a webhook that is called on certain
A JSON object is sent in a POST request to the webhook URL with the following structure:
```json
{
"event": "peer_created",
"event": "update",
"entity": "peer",
"identifier": "the-peer-identifier",
"payload": {
@ -650,6 +686,8 @@ A JSON object is sent in a POST request to the webhook URL with the following st
}
```
Further details can be found in the [usage documentation](../usage/webhooks.md).
### `url`
- **Default:** *(empty)*
- **Description:** The POST endpoint to which the webhook is sent. The URL must be reachable from the WireGuard Portal server. If the URL is empty, the webhook is disabled.

View File

@ -0,0 +1,57 @@
This documentation section describes the general usage of WireGuard Portal.
If you are looking for specific setup instructions, please refer to the *Getting Started* and [*Configuration*](../configuration/overview.md) sections,
for example, using a [Docker](../getting-started/docker.md) deployment.
## Basic Concepts
WireGuard Portal is a web-based configuration portal for WireGuard server management. It allows managing multiple WireGuard interfaces and users from a single web UI.
WireGuard Interfaces can be categorized into three types:
- **Server**: A WireGuard server interface that to which multiple peers can connect. In this mode, it is possible to specify default settings for all peers, such as the IP address range, DNS servers, and MTU size.
- **Client**: A WireGuard client interface that can be used to connect to a WireGuard server. Usually, such an interface has exactly one peer.
- **Unknown**: This is the default type for imported interfaces. It is encouraged to change the type to either `Server` or `Client` after importing the interface.
## Accessing the Web UI
The web UI should be accessed via the URL specified in the `external_url` property of the configuration file.
By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) section for more information on securing the web UI.
So the default URL to access the web UI is:
```
http://localhost:8888
```
A freshly set-up WireGuard Portal instance will have a default admin user with the username `admin@wgportal.local` and the password `wgportal-default`.
You can and should override the default credentials in the configuration file. Make sure to change the default password immediately after the first login!
### Basic UI Description
![WireGuard Portal Web UI](../../assets/images/landing_page.png)
As seen in the screenshot above, the web UI is divided into several sections which are accessible via the navigation bar on the top of the screen.
1. **Home**: The landing page of WireGuard Portal. It provides a staring point for the user to access the different sections of the web UI. It also provides quick links to WireGuard Client downloads or official documentation.
2. **Interfaces**: This section allows you to manage the WireGuard interfaces. You can add, edit, or delete interfaces, as well as view their status and statistics. Peers for each interface can be managed here as well.
3. **Users**: This section allows you to manage the users of WireGuard Portal. You can add, edit, or delete users, as well as view their status and statistics.
4. **Key Generator**: This section allows you to generate WireGuard keys locally on your browser. The generated keys are never sent to the server. This is useful if you want to generate keys for a new peer without having to store the private keys in the database.
5. **Profile / Settings**: This section allows you to access your own profile page, settings, and audit logs.
### Interface View
![WireGuard Portal Interface View](../../assets/images/interface_view.png)
The interface view provides an overview of the WireGuard interfaces and peers configured in WireGuard Portal.
The most important elements are:
1. **Interface Selector**: This dropdown allows you to select the WireGuard interface you want to manage.
All further actions will be performed on the selected interface.
2. **Create new Interface**: This button allows you to create a new WireGuard interface.
3. **Interface Overview**: This section provides an overview of the selected WireGuard interface. It shows the interface type, number of peers, and other important information.
4. **List of Peers**: This section provides a list of all peers associated with the selected WireGuard interface. You can view, add, edit, or delete peers from this list.
5. **Add new Peer**: This button allows you to add a new peer to the selected WireGuard interface.
6. **Add multiple Peers**: This button allows you to add multiple peers to the selected WireGuard interface.
This is useful if you want to add a large number of peers at once.

View File

@ -0,0 +1,37 @@
WireGuard Portal lets you hook up any LDAP server such as Active Directory or OpenLDAP for both authentication and user sync.
You can even register multiple LDAP servers side-by-side. When someone logs in via LDAP, their specific provider is remembered,
so there's no risk of cross-provider conflicts. Details on the log-in process can be found in the [Security](security.md#ldap-authentication) documentation.
If you enable LDAP synchronization, all users within the LDAP directory will be created automatically in the WireGuard Portal database if they do not exist.
If a user is disabled or deleted in LDAP, the user will be disabled in WireGuard Portal as well.
The synchronization process can be fine-tuned by multiple parameters, which are described below.
## LDAP Synchronization
WireGuard Portal can automatically synchronize users from LDAP to the database.
To enable this feature, set the `sync_interval` property in the LDAP provider configuration to a value greater than "0".
The value is a string representing a duration, such as "15m" for 15 minutes or "1h" for 1 hour (check the [exact format definition](https://pkg.go.dev/time#ParseDuration) for details).
The synchronization process will run in the background and synchronize users from LDAP to the database at the specified interval.
Also make sure that the `sync_filter` property is a well-formed LDAP filter, or synchronization will fail.
### Limiting Synchronization to Specific Users
Use the `sync_filter` property in your LDAP provider block to restrict which users get synchronized.
It accepts any valid LDAP search filter, only entries matching that filter will be pulled into the portal's database.
For example, to import only users with a `mail` attribute:
```yaml
auth:
ldap:
- id: ldap
# ... other settings
sync_filter: (mail=*)
```
### Disable Missing Users
If you set the `disable_missing` property to `true`, any user that is not found in LDAP during synchronization will be disabled in WireGuard Portal.
All peers associated with that user will also be disabled.
If you want a user and its peers to be automatically re-enabled once they are found in LDAP again, set the `auto_re_enable` property to `true`.
This will only re-enable the user if they where disabled by the synchronization process. Manually disabled users will not be re-enabled.

View File

@ -0,0 +1,160 @@
This section describes the security features available to administrators for hardening WireGuard Portal and protecting its data.
## Authentication
WireGuard Portal supports multiple authentication methods, including:
- Local user accounts
- LDAP authentication
- OAuth and OIDC authentication
- Passkey authentication (WebAuthn)
Users can have two roles which limit their permissions in WireGuard Portal:
- **User**: Can manage their own account and peers.
- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces.
### Password Security
WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts.
Local users are stored in the database, while LDAP users are authenticated against an external LDAP server.
On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`.
> :warning: This password must be changed immediately after the first login.
The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth)
section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length).
The minimum password length is also enforced for the default admin user.
### Passkey (WebAuthn) Authentication
Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication.
This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file.
Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked.
> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback).
To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button.
![Passkey UI](../../assets/images/passkey_setup.png)
### OAuth and OIDC Authentication
WireGuard Portal supports OAuth and OIDC authentication. You can use any OAuth or OIDC provider that supports the authorization code flow,
such as Google, GitHub, or Keycloak.
For OAuth or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file.
If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS.
To add OIDC or OAuth authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth provider and
configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md).
#### Limiting Login to Specific Domains
You can limit the login to specific domains by setting the `allowed_domains` property for OAuth or OIDC providers.
This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list.
For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_domains:
- "outlook.com"
```
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth or OIDC providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their attributes in the OAuth or OIDC provider. To do this, set the `admin_mapping` property for the provider.
Administrative access can either be mapped by a specific attribute or by group membership.
**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property.
The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute.
The user is granted admin access if the regex matches the attribute value.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
is_admin: "wg_admin_prop"
admin_mapping:
admin_value_regex: "^true$"
```
The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`.
**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property.
The `admin_group_regex` property is a regular expression that is matched against the group names of the user.
The user is granted admin access if the regex matches any of the group names.
Example:
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
field_map:
user_groups: "groups"
admin_mapping:
admin_group_regex: "^the-admin-group$"
```
The example above will grant admin access to users who are members of the `the-admin-group` group.
### LDAP Authentication
WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP.
Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers.
To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file.
#### Limiting Login to Specific Users
You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax.
The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login.
For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows:
```yaml
auth:
ldap:
- provider_name: "ldap1"
# ... other settings
login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))"
```
The `login_filter` should always be designed to return at most one user.
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers.
If registration is enabled, new users will be created in the database when they log in for the first time.
#### Admin Mapping
You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider.
The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin.
All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access.
## UI and API Access
WireGuard Portal provides a web UI and a REST API for user interaction. It is important to secure these interfaces to prevent unauthorized access and data breaches.
### HTTPS
It is recommended to use HTTPS for all communication with the portal to prevent eavesdropping.
Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features.
A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section.

View File

@ -0,0 +1,86 @@
Webhooks allow WireGuard Portal to notify external services about events such as user creation, device changes, or configuration updates. This enables integration with other systems and automation workflows.
When webhooks are configured and a specified event occurs, WireGuard Portal sends an HTTP **POST** request to the configured webhook URL.
The payload contains event-specific data in JSON format.
## Configuration
All available configuration options for webhooks can be found in the [configuration overview](../configuration/overview.md#webhook).
A basic webhook configuration looks like this:
```yaml
webhook:
url: https://your-service.example.com/webhook
```
### Security
Webhooks can be secured by using a shared secret. This secret is included in the `Authorization` header of the webhook request, allowing your service to verify the authenticity of the request.
You can set the shared secret in the webhook configuration:
```yaml
webhook:
url: https://your-service.example.com/webhook
secret: "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
```
You should also make sure that your webhook endpoint is secured with HTTPS to prevent eavesdropping and tampering.
## Available Events
WireGuard Portal supports various events that can trigger webhooks. The following events are available:
- `create`: Triggered when a new entity is created.
- `update`: Triggered when an existing entity is updated.
- `delete`: Triggered when an entity is deleted.
- `connect`: Triggered when a user connects to the VPN.
- `disconnect`: Triggered when a user disconnects from the VPN.
The following entity types can trigger webhooks:
- `user`: When a WireGuard Portal user is created, updated, or deleted.
- `peer`: When a peer is created, updated, or deleted. This entity can also trigger `connect` and `disconnect` events.
- `interface`: When a device is created, updated, or deleted.
## Payload Structure
All webhook events send a JSON payload containing relevant data. The structure of the payload depends on the event type and entity involved.
A common shell structure for webhook payloads is as follows:
```json
{
"event": "create",
"entity": "user",
"identifier": "the-user-identifier",
"payload": {
// The payload of the event, e.g. peer data.
// Check the API documentation for the exact structure.
}
}
```
### Example Payload
The following payload is an example of a webhook event when a peer connects to the VPN:
```json
{
"event": "connect",
"entity": "peer",
"identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"payload": {
"PeerId": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
"IsConnected": true,
"IsPingable": false,
"LastPing": null,
"BytesReceived": 1860,
"BytesTransmitted": 10824,
"LastHandshake": "2025-06-26T23:04:33.325216659+02:00",
"Endpoint": "10.55.66.77:33874",
"LastSessionStart": "2025-06-26T22:50:40.10221606+02:00"
}
}
```

View File

@ -12,6 +12,7 @@
"@fortawesome/fontawesome-free": "^6.7.2",
"@kyvg/vue3-notification": "^3.4.1",
"@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.1.0",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.5",
"bootswatch": "^5.3.5",
@ -863,6 +864,12 @@
"win32"
]
},
"node_modules/@simplewebauthn/browser": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz",
"integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",

View File

@ -12,6 +12,7 @@
"@fortawesome/fontawesome-free": "^6.7.2",
"@kyvg/vue3-notification": "^3.4.1",
"@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.1.0",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"bootstrap": "^5.3.5",
"bootswatch": "^5.3.5",

View File

@ -116,7 +116,7 @@ const userDisplayName = computed(() => {
href="#" role="button">{{ userDisplayName }}</a>
<div class="dropdown-menu">
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink>
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly') || settings.Setting('WebAuthnEnabled')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink>
<RouterLink :to="{ name: 'audit' }" class="dropdown-item" v-if="auth.IsAdmin"><i class="fas fa-file-shield"></i> {{ $t('menu.audit') }}</RouterLink>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a>

View File

@ -5,10 +5,12 @@ import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import {freshUser} from "@/helpers/models";
import {settingsStore} from "@/stores/settings";
const { t } = useI18n()
const users = userStore()
const settings = settingsStore()
const props = defineProps({
userId: String,
@ -33,6 +35,30 @@ const title = computed(() => {
const formData = ref(freshUser())
const passwordWeak = computed(() => {
return formData.value.Password && formData.value.Password.length > 0 && formData.value.Password.length < settings.Setting('MinPasswordLength')
})
const formValid = computed(() => {
if (formData.value.Source !== 'db') {
return true // nothing to validate
}
if (props.userId !== '#NEW#' && passwordWeak.value) {
return false
}
if (props.userId === '#NEW#' && (!formData.value.Password || formData.value.Password.length < 1)) {
return false
}
if (props.userId === '#NEW#' && passwordWeak.value) {
return false
}
if (!formData.value.Identifier || formData.value.Identifier.length < 1) {
return false
}
return true
})
// functions
watch(() => props.visible, async (newValue, oldValue) => {
@ -109,7 +135,8 @@ async function del() {
</div>
<div v-if="formData.Source==='db'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :placeholder="$t('modals.user-edit.password.placeholder')" type="text">
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': formData.Password !== '' && !passwordWeak }" :placeholder="$t('modals.user-edit.password.placeholder')" type="password">
<div class="invalid-feedback">{{ $t('modals.user-edit.password.too-weak') }}</div>
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
</div>
</fieldset>
@ -168,7 +195,7 @@ async function del() {
<div class="flex-fill text-start">
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
</div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button>
<button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="!formValid">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template>
</Modal>

View File

@ -29,7 +29,8 @@
"label": "Passwort",
"placeholder": "Bitte geben Sie Ihr Passwort ein"
},
"button": "Anmelden"
"button": "Anmelden",
"button-webauthn": "Passkey verwenden"
},
"menu": {
"home": "Home",
@ -188,6 +189,35 @@
"button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.",
"button-enable-text": "API aktivieren",
"api-link": "API Dokumentation"
},
"webauthn": {
"headline": "Passkey-Einstellungen",
"abstract": "Passkeys sind eine moderne Möglichkeit, Benutzer ohne Passwort zu authentifizieren. Sie werden sicher in Ihrem Browser gespeichert und können verwendet werden, um sich im WireGuard-Portal anzumelden.",
"active-description": "Mindestens ein Passkey ist derzeit für Ihr Benutzerkonto aktiv.",
"inactive-description": "Für Ihr Benutzerkonto sind derzeit keine Passkeys registriert. Drücken Sie die Schaltfläche unten, um einen neuen Passkey zu registrieren.",
"table": {
"name": "Name",
"created": "Erstellt",
"actions": ""
},
"credentials-list": "Derzeit registrierte Passkeys",
"modal-delete": {
"headline": "Passkey löschen",
"abstract": "Sind Sie sicher, dass Sie diesen Passkey löschen möchten? Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
"created": "Erstellt:",
"button-delete": "Löschen",
"button-cancel": "Abbrechen"
},
"button-rename-title": "Umbenennen",
"button-rename-text": "Passkey umbenennen.",
"button-save-title": "Speichern",
"button-save-text": "Neuen Namen des Passkeys speichern.",
"button-cancel-title": "Abbrechen",
"button-cancel-text": "Umbenennung des Passkeys abbrechen.",
"button-delete-title": "Löschen",
"button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.",
"button-register-title": "Passkey registrieren",
"button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern."
}
},
"audit": {
@ -266,7 +296,8 @@
"password": {
"label": "Passwort",
"placeholder": "Ein super geheimes Passwort",
"description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten."
"description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten.",
"too-weak": "Das Passwort entspricht nicht den Sicherheitsanforderungen."
},
"email": {
"label": "E-Mail",

View File

@ -29,7 +29,8 @@
"label": "Password",
"placeholder": "Please enter your password"
},
"button": "Sign in"
"button": "Sign in",
"button-webauthn": "Use Passkey"
},
"menu": {
"home": "Home",
@ -188,6 +189,35 @@
"button-enable-title": "Enable API, this will generate a new token.",
"button-enable-text": "Enable API",
"api-link": "API Documentation"
},
"webauthn": {
"headline": "Passkey Settings",
"abstract": "Passkeys are a modern way to authenticate users without the need for passwords. They are stored securely in your browser and can be used to log in to the WireGuard Portal.",
"active-description": "At least one passkey is currently active for your user account.",
"inactive-description": "No passkeys are currently registered for your user account. Press the button below to register a new passkey.",
"table": {
"name": "Name",
"created": "Created",
"actions": ""
},
"credentials-list": "Currently registered Passkeys",
"modal-delete": {
"headline": "Delete Passkey",
"abstract": "Are you sure you want to delete this passkey? You will not be able to log in with this passkey anymore.",
"created": "Created:",
"button-delete": "Delete",
"button-cancel": "Cancel"
},
"button-rename-title": "Rename",
"button-rename-text": "Rename the passkey.",
"button-save-title": "Save",
"button-save-text": "Save the new name of the passkey.",
"button-cancel-title": "Cancel",
"button-cancel-text": "Cancel the renaming of the passkey.",
"button-delete-title": "Delete",
"button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.",
"button-register-title": "Register Passkey",
"button-register-text": "Register a new Passkey to secure your account."
}
},
"audit": {
@ -266,7 +296,8 @@
"password": {
"label": "Password",
"placeholder": "A super secret password",
"description": "Leave this field blank to keep current password."
"description": "Leave this field blank to keep current password.",
"too-weak": "The password is too weak. Please use a stronger password."
},
"email": {
"label": "Email",

View File

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

View File

@ -65,7 +65,7 @@ const auth = authStore()
<div class="card-body d-flex flex-column">
<h4 class="card-title">{{ $t('home.about-portal.headline') }}</h4>
<p class="card-text">{{ $t('home.about-portal.content') }}</p>
<a href="https://github.com/h44z/wg-portal/" title="WireGuard Portal" target="_blank"
<a href="https://wgportal.org/" title="WireGuard Portal" target="_blank"
rel="noopener noreferrer" class="mt-auto btn btn-primary btn-sm">{{ $t('home.about-portal.button') }}</a>
</div>
</div>

View File

@ -1,6 +1,6 @@
<script setup>
import {computed, ref} from "vue";
import {computed, onMounted, ref} from "vue";
import {authStore} from "@/stores/auth";
import router from '../router/index.js'
import {notify} from "@kyvg/vue3-notification";
@ -16,6 +16,14 @@ const password = ref("")
const usernameInvalid = computed(() => username.value === "")
const passwordInvalid = computed(() => password.value === "")
const disableLoginBtn = computed(() => username.value === "" || password.value === "" || loggingIn.value)
const showLoginForm = computed(() => {
console.log(router.currentRoute.value.query)
return settings.Setting('LoginFormVisible') || router.currentRoute.value.query.hasOwnProperty('all');
});
onMounted(async () => {
await settings.LoadSettings()
})
const login = async function () {
console.log("Performing login for user:", username.value);
@ -28,7 +36,34 @@ const login = async function () {
type: 'success',
});
loggingIn.value = false;
settings.LoadSettings(); // only logs errors, does not throw
settings.LoadSettings(); // reload full settings
router.push(auth.ReturnUrl);
})
.catch(error => {
notify({
title: "Login failed!",
text: "Authentication failed!",
type: 'error',
});
//loggingIn.value = false;
// delay the user from logging in for a short amount of time
setTimeout(() => loggingIn.value = false, 1000);
});
}
const loginWebAuthn = async function () {
console.log("Performing webauthn login");
loggingIn.value = true;
auth.LoginWebAuthn()
.then(uid => {
notify({
title: "Logged in",
text: "Authentication succeeded!",
type: 'success',
});
loggingIn.value = false;
settings.LoadSettings(); // reload full settings
router.push(auth.ReturnUrl);
})
.catch(error => {
@ -66,7 +101,7 @@ const externalLogin = function (provider) {
</div></div>
<div class="card-body">
<form method="post">
<fieldset>
<fieldset v-if="showLoginForm">
<div class="form-group">
<label class="form-label" for="inputUsername">{{ $t('login.username.label') }}</label>
<div class="input-group mb-3">
@ -85,13 +120,42 @@ const externalLogin = function (provider) {
</div>
</div>
<div class="row mt-5 d-flex">
<div :class="{'col-lg-4':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
<button :disabled="disableLoginBtn" class="btn btn-primary flex-fill" type="submit" @click.prevent="login">
<div class="row mt-5 mb-2">
<div class="col-sm-4 col-xs-12">
<button :disabled="disableLoginBtn" class="btn btn-primary mb-2" type="submit" @click.prevent="login">
{{ $t('login.button') }} <div v-if="loggingIn" class="d-inline"><i class="ms-2 fa-solid fa-circle-notch fa-spin"></i></div>
</button>
</div>
<div :class="{'col-lg-8':auth.LoginProviders.length < 3, 'col-lg-12':auth.LoginProviders.length >= 3}" class="d-flex mb-2">
<div class="col-sm-8 col-xs-12 text-sm-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-4 d-flex">
<div class="col-lg-12 d-flex mb-2">
<!-- OpenIdConnect / OAUTH providers -->
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
</div>
</div>
<div class="mt-3">
</div>
</fieldset>
<fieldset v-else>
<div class="row mt-1 mb-2" v-if="settings.Setting('WebAuthnEnabled')">
<div class="col-lg-12 d-flex mb-2">
<button class="btn btn-outline-primary flex-fill" 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-1 d-flex">
<div class="col-lg-12 d-flex mb-2">
<!-- OpenIdConnect / OAUTH providers -->
<button v-for="(provider, idx) in auth.LoginProviders" :key="provider.Identifier" :class="{'ms-1':idx > 0}"
:disabled="loggingIn" :title="provider.Name" class="btn btn-outline-primary flex-fill"
@ -104,7 +168,6 @@ const externalLogin = function (provider) {
</fieldset>
</form>
</div>
</div>
</div>

View File

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

25
go.mod
View File

@ -10,6 +10,7 @@ require (
github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-pkgz/routegroup v1.4.1
github.com/go-playground/validator/v10 v10.26.0
github.com/go-webauthn/webauthn v0.13.0
github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.22.0
@ -20,15 +21,15 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.38.0
golang.org/x/crypto v0.39.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.33.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlserver v1.5.4
gorm.io/gorm v1.26.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.6.0
gorm.io/gorm v1.30.0
)
require (
@ -39,6 +40,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
@ -51,9 +53,12 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-webauthn/x v0.1.21 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.4 // indirect
@ -69,6 +74,7 @@ require (
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/microsoft/go-mssqldb v1.8.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@ -78,12 +84,13 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.32.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/tools v0.33.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
google.golang.org/protobuf v1.36.6 // indirect
modernc.org/libc v1.63.0 // indirect

165
go.sum
View File

@ -1,16 +1,13 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
@ -19,8 +16,7 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occ
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
@ -44,6 +40,8 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@ -74,26 +72,34 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/go-webauthn/webauthn v0.13.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqAIsWs0Y=
github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs=
github.com/go-webauthn/x v0.1.21 h1:nFbckQxudvHEJn2uy1VEi713MeSpApoAv9eRqsb9AdQ=
github.com/go-webauthn/x v0.1.21/go.mod h1:sEYohtg1zL4An1TXIUIQ5csdmoO+WO0R4R2pGKaHYKA=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
@ -113,10 +119,12 @@ github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFK
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
@ -149,17 +157,20 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/microsoft/go-mssqldb v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook=
github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/montanaflynn/stats v0.6.6/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/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
@ -180,15 +191,10 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
@ -202,6 +208,8 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
@ -212,90 +220,101 @@ github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
@ -306,23 +325,23 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=

View File

@ -220,6 +220,8 @@ func (r *SqlRepo) preCheck() error {
func (r *SqlRepo) migrate() error {
slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{}))
slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{}))
slog.Debug("running migration: user webauthn credentials", "result",
r.db.AutoMigrate(&domain.UserWebauthnCredential{}))
slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{}))
slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{}))
slog.Debug("running migration: peer status", "result", r.db.AutoMigrate(&domain.PeerStatus{}))
@ -746,7 +748,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr
func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) {
var user domain.User
err := r.db.WithContext(ctx).First(&user, id).Error
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
@ -764,7 +766,7 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai
func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var users []domain.User
err := r.db.WithContext(ctx).Where("email = ?", email).Find(&users).Error
err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
}
@ -785,11 +787,26 @@ func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Use
return &user, nil
}
// GetUserByWebAuthnCredential returns the user with the given webauthn credential id.
func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
var credential domain.UserWebauthnCredential
err := r.db.WithContext(ctx).Where("credential_identifier = ?", credentialIdBase64).First(&credential).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return r.GetUser(ctx, domain.UserIdentifier(credential.UserIdentifier))
}
// GetAllUsers returns all users.
func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) {
var users []domain.User
err := r.db.WithContext(ctx).Find(&users).Error
err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error
if err != nil {
return nil, err
}
@ -808,6 +825,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User,
Or("firstname LIKE ?", searchValue).
Or("lastname LIKE ?", searchValue).
Or("email LIKE ?", searchValue).
Preload("WebAuthnCredentialList").
Find(&users).Error
if err != nil {
return nil, err
@ -853,7 +871,7 @@ func (r *SqlRepo) SaveUser(
// DeleteUser deletes the user with the given id.
func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error {
err := r.db.WithContext(ctx).Delete(&domain.User{}, id).Error
err := r.db.WithContext(ctx).Unscoped().Select(clause.Associations).Delete(&domain.User{Identifier: id}).Error
if err != nil {
return err
}
@ -897,6 +915,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma
return err
}
err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Unscoped().Model(user).Association("WebAuthnCredentialList").Unscoped().Replace(user.WebAuthnCredentialList)
if err != nil {
return fmt.Errorf("failed to update users webauthn credentials: %w", err)
}
return nil
}

View File

@ -133,5 +133,5 @@ func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerS
}
m.peerReceivedBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesReceived))
m.peerSendBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesTransmitted))
m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected()))
m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected))
}

View File

@ -175,30 +175,78 @@
}
}
},
"/auth/{provider}/callback": {
"get": {
"/auth/webauthn/credential/{id}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Handle the OAuth callback.",
"operationId": "auth_handleOauthCallbackGet",
"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.LoginProviderInfo"
"$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/{provider}/init": {
"/auth/webauthn/credentials": {
"get": {
"produces": [
"application/json"
@ -206,15 +254,67 @@
"tags": [
"Authentication"
],
"summary": "Initiate the OAuth login flow.",
"operationId": "auth_handleOauthInitiateGet",
"summary": "Get all available external login providers.",
"operationId": "auth_handleWebAuthnCredentialsGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.LoginProviderInfo"
"$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"
}
}
}
@ -2131,14 +2231,23 @@
"ApiAdminOnly": {
"type": "boolean"
},
"LoginFormVisible": {
"type": "boolean"
},
"MailLinkOnly": {
"type": "boolean"
},
"MinPasswordLength": {
"type": "integer"
},
"PersistentConfigSupported": {
"type": "boolean"
},
"SelfProvisioning": {
"type": "boolean"
},
"WebAuthnEnabled": {
"type": "boolean"
}
}
},
@ -2207,6 +2316,28 @@
"type": "string"
}
}
},
"model.WebAuthnCredentialRequest": {
"type": "object",
"properties": {
"Name": {
"type": "string"
}
}
},
"model.WebAuthnCredentialResponse": {
"type": "object",
"properties": {
"CreatedAt": {
"type": "string"
},
"ID": {
"type": "string"
},
"Name": {
"type": "string"
}
}
}
}
}

View File

@ -381,6 +381,8 @@ definitions:
properties:
ApiAdminOnly:
type: boolean
LoginFormVisible:
type: boolean
MailLinkOnly:
type: boolean
MinPasswordLength:
@ -389,6 +391,8 @@ definitions:
type: boolean
SelfProvisioning:
type: boolean
WebAuthnEnabled:
type: boolean
type: object
model.User:
properties:
@ -435,6 +439,20 @@ definitions:
Source:
type: string
type: object
model.WebAuthnCredentialRequest:
properties:
Name:
type: string
type: object
model.WebAuthnCredentialResponse:
properties:
CreatedAt:
type: string
ID:
type: string
Name:
type: string
type: object
info:
contact:
name: WireGuard Portal Developers
@ -550,6 +568,102 @@ paths:
summary: Get information about the currently logged-in user.
tags:
- Authentication
/auth/webauthn/credential/{id}:
delete:
operationId: auth_handleWebAuthnCredentialsDelete
parameters:
- description: Base64 encoded Credential ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.WebAuthnCredentialResponse'
type: array
summary: Delete a WebAuthn credential.
tags:
- Authentication
put:
operationId: auth_handleWebAuthnCredentialsPut
parameters:
- description: Base64 encoded Credential ID
in: path
name: id
required: true
type: string
- description: Credential name
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.WebAuthnCredentialRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.WebAuthnCredentialResponse'
type: array
summary: Update a WebAuthn credential.
tags:
- Authentication
/auth/webauthn/credentials:
get:
operationId: auth_handleWebAuthnCredentialsGet
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.WebAuthnCredentialResponse'
type: array
summary: Get all available external login providers.
tags:
- Authentication
/auth/webauthn/login/finish:
post:
operationId: auth_handleWebAuthnLoginFinish
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
summary: Finish the WebAuthn login process.
tags:
- Authentication
/auth/webauthn/register/finish:
post:
operationId: auth_handleWebAuthnRegisterFinish
parameters:
- default: '""'
description: Credential name
in: query
name: credential_name
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.WebAuthnCredentialResponse'
type: array
summary: Finish the WebAuthn registration process.
tags:
- Authentication
/config/frontend.js:
get:
operationId: config_handleConfigJsGet

View File

@ -99,6 +99,8 @@ type Authenticator interface {
LoggedIn(scopes ...Scope) func(next http.Handler) http.Handler
// UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted.
UserIdMatch(idParameter string) func(next http.Handler) http.Handler
// InfoOnly only add user info to the request context. No login check is performed.
InfoOnly() func(next http.Handler) http.Handler
}
type Session interface {

View File

@ -29,12 +29,54 @@ type AuthenticationService interface {
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
}
type WebAuthnService interface {
Enabled() bool
StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
responseOptions []byte,
sessionData []byte,
err error,
)
FinishWebAuthnRegistration(
ctx context.Context,
userId domain.UserIdentifier,
name string,
sessionDataAsJSON []byte,
r *http.Request,
) ([]domain.UserWebauthnCredential, error)
GetCredentials(
ctx context.Context,
userId domain.UserIdentifier,
) ([]domain.UserWebauthnCredential, error)
RemoveCredential(
ctx context.Context,
userId domain.UserIdentifier,
credentialIdBase64 string,
) ([]domain.UserWebauthnCredential, error)
UpdateCredential(
ctx context.Context,
userId domain.UserIdentifier,
credentialIdBase64 string,
name string,
) ([]domain.UserWebauthnCredential, error)
StartWebAuthnLogin(_ context.Context) (
optionsAsJSON []byte,
sessionDataAsJSON []byte,
err error,
)
FinishWebAuthnLogin(
ctx context.Context,
sessionDataAsJSON []byte,
r *http.Request,
) (*domain.User, error)
}
type AuthEndpoint struct {
cfg *config.Config
authService AuthenticationService
authenticator Authenticator
session Session
validate Validator
webAuthn WebAuthnService
}
func NewAuthEndpoint(
@ -43,6 +85,7 @@ func NewAuthEndpoint(
session Session,
validator Validator,
authService AuthenticationService,
webAuthn WebAuthnService,
) AuthEndpoint {
return AuthEndpoint{
cfg: cfg,
@ -50,6 +93,7 @@ func NewAuthEndpoint(
authenticator: authenticator,
session: session,
validate: validator,
webAuthn: webAuthn,
}
}
@ -66,6 +110,19 @@ func (e AuthEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet())
apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet())
apiGroup.HandleFunc("POST /webauthn/login/start", e.handleWebAuthnLoginStart())
apiGroup.HandleFunc("POST /webauthn/login/finish", e.handleWebAuthnLoginFinish())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /webauthn/credentials",
e.handleWebAuthnCredentialsGet())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/start",
e.handleWebAuthnRegisterStart())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/finish",
e.handleWebAuthnRegisterFinish())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("DELETE /webauthn/credential/{id}",
e.handleWebAuthnCredentialsDelete())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("PUT /webauthn/credential/{id}",
e.handleWebAuthnCredentialsPut())
apiGroup.HandleFunc("POST /login", e.handleLoginPost())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
}
@ -398,3 +455,237 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
return true
}
// handleWebAuthnCredentialsGet returns a gorm Handler function.
//
// @ID auth_handleWebAuthnCredentialsGet
// @Tags Authentication
// @Summary Get all available external login providers.
// @Produce json
// @Success 200 {object} []model.WebAuthnCredentialResponse
// @Router /auth/webauthn/credentials [get]
func (e AuthEndpoint) handleWebAuthnCredentialsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusOK, []model.WebAuthnCredentialResponse{})
return
}
currentSession := e.session.GetData(r.Context())
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
credentials, err := e.webAuthn.GetCredentials(r.Context(), userIdentifier)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
}
}
// handleWebAuthnCredentialsDelete returns a gorm Handler function.
//
// @ID auth_handleWebAuthnCredentialsDelete
// @Tags Authentication
// @Summary Delete a WebAuthn credential.
// @Param id path string true "Base64 encoded Credential ID"
// @Produce json
// @Success 200 {object} []model.WebAuthnCredentialResponse
// @Router /auth/webauthn/credential/{id} [delete]
func (e AuthEndpoint) handleWebAuthnCredentialsDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
credentialId := Base64UrlDecode(request.Path(r, "id"))
credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
}
}
// handleWebAuthnCredentialsPut returns a gorm Handler function.
//
// @ID auth_handleWebAuthnCredentialsPut
// @Tags Authentication
// @Summary Update a WebAuthn credential.
// @Param id path string true "Base64 encoded Credential ID"
// @Param request body model.WebAuthnCredentialRequest true "Credential name"
// @Produce json
// @Success 200 {object} []model.WebAuthnCredentialResponse
// @Router /auth/webauthn/credential/{id} [put]
func (e AuthEndpoint) handleWebAuthnCredentialsPut() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
credentialId := Base64UrlDecode(request.Path(r, "id"))
var req model.WebAuthnCredentialRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
credentials, err := e.webAuthn.UpdateCredential(r.Context(), userIdentifier, credentialId, req.Name)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
}
}
func (e AuthEndpoint) handleWebAuthnRegisterStart() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
options, sessionData, err := e.webAuthn.StartWebAuthnRegistration(r.Context(), userIdentifier)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
currentSession.WebAuthnData = string(sessionData)
e.session.SetData(r.Context(), currentSession)
respond.Data(w, http.StatusOK, "application/json", options)
}
}
// handleWebAuthnRegisterFinish returns a gorm Handler function.
//
// @ID auth_handleWebAuthnRegisterFinish
// @Tags Authentication
// @Summary Finish the WebAuthn registration process.
// @Param credential_name query string false "Credential name" default("")
// @Produce json
// @Success 200 {object} []model.WebAuthnCredentialResponse
// @Router /auth/webauthn/register/finish [post]
func (e AuthEndpoint) handleWebAuthnRegisterFinish() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
name := request.QueryDefault(r, "credential_name", "")
currentSession := e.session.GetData(r.Context())
webAuthnSessionData := []byte(currentSession.WebAuthnData)
currentSession.WebAuthnData = "" // clear the session data
e.session.SetData(r.Context(), currentSession)
credentials, err := e.webAuthn.FinishWebAuthnRegistration(
r.Context(),
domain.UserIdentifier(currentSession.UserIdentifier),
name,
webAuthnSessionData,
r)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
}
}
func (e AuthEndpoint) handleWebAuthnLoginStart() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
options, sessionData, err := e.webAuthn.StartWebAuthnLogin(r.Context())
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
currentSession.WebAuthnData = string(sessionData)
e.session.SetData(r.Context(), currentSession)
respond.Data(w, http.StatusOK, "application/json", options)
}
}
// handleWebAuthnLoginFinish returns a gorm Handler function.
//
// @ID auth_handleWebAuthnLoginFinish
// @Tags Authentication
// @Summary Finish the WebAuthn login process.
// @Produce json
// @Success 200 {object} model.User
// @Router /auth/webauthn/login/finish [post]
func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
webAuthnSessionData := []byte(currentSession.WebAuthnData)
currentSession.WebAuthnData = "" // clear the session data
e.session.SetData(r.Context(), currentSession)
user, err := e.webAuthn.FinishWebAuthnLogin(
r.Context(),
webAuthnSessionData,
r)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
e.setAuthenticatedUser(r, user)
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
}
}

View File

@ -15,6 +15,7 @@ import (
"github.com/h44z/wg-portal/internal/app/api/core/respond"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
//go:embed frontend_config.js.gotpl
@ -46,7 +47,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/config")
apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /settings", e.handleSettingsGet())
apiGroup.With(e.authenticator.InfoOnly()).HandleFunc("GET /settings", e.handleSettingsGet())
}
// handleConfigJsGet returns a gorm Handler function.
@ -93,11 +94,26 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
// @Router /config/settings [get]
func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sessionUser := domain.GetUserInfo(r.Context())
hasSocialLogin := len(e.cfg.Auth.OAuth) > 0 || len(e.cfg.Auth.OpenIDConnect) > 0 || e.cfg.Auth.WebAuthn.Enabled
// For anonymous users, we return the settings object with minimal information
if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" {
respond.JSON(w, http.StatusOK, model.Settings{
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
})
} 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,
MinPasswordLength: e.cfg.Auth.MinPasswordLength,
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
})
}
}
}

View File

@ -72,6 +72,32 @@ func (h AuthenticationHandler) LoggedIn(scopes ...Scope) func(next http.Handler)
}
}
// InfoOnly only checks if the user is logged in and adds the user id to the context.
// If the user is not logged in, the context user id is set to domain.CtxUnknownUserId.
func (h AuthenticationHandler) InfoOnly() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := h.session.GetData(r.Context())
var newContext context.Context
if !session.LoggedIn {
newContext = domain.SetUserInfo(r.Context(), domain.DefaultContextUserInfo())
} else {
newContext = domain.SetUserInfo(r.Context(), &domain.ContextUserInfo{
Id: domain.UserIdentifier(session.UserIdentifier),
IsAdmin: session.IsAdmin,
})
}
r = r.WithContext(newContext)
// Continue down the chain to Handler etc
next.ServeHTTP(w, r)
})
}
}
// UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted.
func (h AuthenticationHandler) UserIdMatch(idParameter string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {

View File

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

View File

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

View File

@ -1,6 +1,11 @@
package model
import "github.com/h44z/wg-portal/internal/domain"
import (
"slices"
"strings"
"github.com/h44z/wg-portal/internal/domain"
)
type LoginProviderInfo struct {
Identifier string `json:"Identifier" example:"google"`
@ -39,3 +44,32 @@ type OauthInitiationResponse struct {
RedirectUrl string
State string
}
type WebAuthnCredentialRequest struct {
Name string `json:"Name"`
}
type WebAuthnCredentialResponse struct {
ID string `json:"ID"`
Name string `json:"Name"`
CreatedAt string `json:"CreatedAt"`
}
func NewWebAuthnCredentialResponse(src domain.UserWebauthnCredential) WebAuthnCredentialResponse {
return WebAuthnCredentialResponse{
ID: src.CredentialIdentifier,
Name: src.DisplayName,
CreatedAt: src.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
func NewWebAuthnCredentialResponses(src []domain.UserWebauthnCredential) []WebAuthnCredentialResponse {
credentials := make([]WebAuthnCredentialResponse, len(src))
for i := range src {
credentials[i] = NewWebAuthnCredentialResponse(src[i])
}
// Sort by CreatedAt, newest first
slices.SortFunc(credentials, func(i, j WebAuthnCredentialResponse) int {
return strings.Compare(i.CreatedAt, j.CreatedAt)
})
return credentials
}

View File

@ -198,7 +198,7 @@ func NewPeerStats(enabled bool, src []domain.PeerStatus) *PeerStats {
for _, srcStat := range src {
stats[string(srcStat.PeerId)] = PeerStatData{
IsConnected: srcStat.IsConnected(),
IsConnected: srcStat.IsConnected,
IsPingable: srcStat.IsPingable,
LastPing: srcStat.LastPing,
BytesReceived: srcStat.BytesReceived,

View File

@ -0,0 +1,301 @@
package auth
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type WebAuthnUserManager interface {
// GetUser returns a user by its identifier.
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
// GetUserByWebAuthnCredential returns a user by its WebAuthn ID.
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
// UpdateUser updates an existing user in the database.
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
}
type WebAuthnAuthenticator struct {
webAuthn *webauthn.WebAuthn
users WebAuthnUserManager
bus EventBus
}
func NewWebAuthnAuthenticator(cfg *config.Config, bus EventBus, users WebAuthnUserManager) (
*WebAuthnAuthenticator,
error,
) {
if !cfg.Auth.WebAuthn.Enabled {
return nil, nil
}
extUrl, err := url.Parse(cfg.Web.ExternalUrl)
if err != nil {
return nil, errors.New("failed to parse external URL - required for WebAuthn RP ID")
}
rpId := extUrl.Hostname()
if rpId == "" {
return nil, errors.New("failed to determine Webauthn RPID")
}
// Initialize the WebAuthn authenticator with the provided configuration
awCfg := &webauthn.Config{
RPID: rpId,
RPDisplayName: cfg.Web.SiteTitle,
RPOrigins: []string{cfg.Web.ExternalUrl},
}
webAuthn, err := webauthn.New(awCfg)
if err != nil {
return nil, fmt.Errorf("failed to create Webauthn instance: %w", err)
}
return &WebAuthnAuthenticator{
webAuthn: webAuthn,
users: users,
bus: bus,
}, nil
}
func (a *WebAuthnAuthenticator) Enabled() bool {
return a != nil && a.webAuthn != nil
}
func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
optionsAsJSON []byte,
sessionDataAsJSON []byte,
err error,
) {
user, err := a.users.GetUser(ctx, userId)
if err != nil {
return nil, nil, fmt.Errorf("failed to get user: %w", err)
}
if user.IsLocked() || user.IsDisabled() {
return nil, nil, errors.New("user is locked") // adding passkey to locked user is not allowed
}
if user.WebAuthnId == "" {
user.GenerateWebAuthnId()
user, err = a.users.UpdateUser(ctx, user)
if err != nil {
return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err)
}
}
options, sessionData, err := a.webAuthn.BeginRegistration(user,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
)
if err != nil {
return nil, nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
}
optionsAsJSON, err = json.Marshal(options)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
}
sessionDataAsJSON, err = json.Marshal(sessionData)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
}
return optionsAsJSON, sessionDataAsJSON, nil
}
func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration(
ctx context.Context,
userId domain.UserIdentifier,
name string,
sessionDataAsJSON []byte,
r *http.Request,
) ([]domain.UserWebauthnCredential, error) {
user, err := a.users.GetUser(ctx, userId)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
if user.IsLocked() || user.IsDisabled() {
return nil, errors.New("user is locked") // adding passkey to locked user is not allowed
}
var webAuthnData webauthn.SessionData
err = json.Unmarshal(sessionDataAsJSON, &webAuthnData)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
}
credential, err := a.webAuthn.FinishRegistration(user, webAuthnData, r)
if err != nil {
return nil, err
}
if name == "" {
name = fmt.Sprintf("Passkey %d", len(user.WebAuthnCredentialList)+1) // fallback name
}
// Add the credential to the user
err = user.AddCredential(userId, name, *credential)
if err != nil {
return nil, err
}
user, err = a.users.UpdateUser(ctx, user)
if err != nil {
return nil, err
}
return user.WebAuthnCredentialList, nil
}
func (a *WebAuthnAuthenticator) GetCredentials(
ctx context.Context,
userId domain.UserIdentifier,
) ([]domain.UserWebauthnCredential, error) {
user, err := a.users.GetUser(ctx, userId)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
return user.WebAuthnCredentialList, nil
}
func (a *WebAuthnAuthenticator) RemoveCredential(
ctx context.Context,
userId domain.UserIdentifier,
credentialIdBase64 string,
) ([]domain.UserWebauthnCredential, error) {
user, err := a.users.GetUser(ctx, userId)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
user.RemoveCredential(credentialIdBase64)
user, err = a.users.UpdateUser(ctx, user)
if err != nil {
return nil, err
}
return user.WebAuthnCredentialList, nil
}
func (a *WebAuthnAuthenticator) UpdateCredential(
ctx context.Context,
userId domain.UserIdentifier,
credentialIdBase64 string,
name string,
) ([]domain.UserWebauthnCredential, error) {
user, err := a.users.GetUser(ctx, userId)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
err = user.UpdateCredential(credentialIdBase64, name)
if err != nil {
return nil, err
}
user, err = a.users.UpdateUser(ctx, user)
if err != nil {
return nil, err
}
return user.WebAuthnCredentialList, nil
}
func (a *WebAuthnAuthenticator) StartWebAuthnLogin(_ context.Context) (
optionsAsJSON []byte,
sessionDataAsJSON []byte,
err error,
) {
options, sessionData, err := a.webAuthn.BeginDiscoverableLogin()
if err != nil {
return nil, nil, fmt.Errorf("failed to begin WebAuthn login: %w", err)
}
optionsAsJSON, err = json.Marshal(options)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
}
sessionDataAsJSON, err = json.Marshal(sessionData)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
}
return optionsAsJSON, sessionDataAsJSON, nil
}
func (a *WebAuthnAuthenticator) FinishWebAuthnLogin(
ctx context.Context,
sessionDataAsJSON []byte,
r *http.Request,
) (*domain.User, error) {
var webAuthnData webauthn.SessionData
err := json.Unmarshal(sessionDataAsJSON, &webAuthnData)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
}
// switch to admin context for user lookup
ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo())
credential, err := a.webAuthn.FinishDiscoverableLogin(a.findUserForWebAuthnSecretFn(ctx), webAuthnData, r)
if err != nil {
return nil, err
}
// Find the user by the WebAuthn ID
user, err := a.users.GetUserByWebAuthnCredential(ctx,
base64.StdEncoding.EncodeToString(credential.ID))
if err != nil {
return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
}
if user.IsLocked() || user.IsDisabled() {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "passkey",
Event: audit.AuthEvent{
Username: string(user.Identifier), Error: "User is locked",
},
})
return nil, errors.New("user is locked") // login with passkey is not allowed
}
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "passkey",
Event: audit.AuthEvent{
Username: string(user.Identifier),
},
})
return user, nil
}
func (a *WebAuthnAuthenticator) findUserForWebAuthnSecretFn(ctx context.Context) func(rawID, userHandle []byte) (
user webauthn.User,
err error,
) {
return func(rawID, userHandle []byte) (webauthn.User, error) {
// Find the user by the WebAuthn ID
user, err := a.users.GetUserByWebAuthnCredential(ctx, base64.StdEncoding.EncodeToString(rawID))
if err != nil {
return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
}
return user, nil
}
}

View File

@ -36,6 +36,7 @@ const TopicPeerDeleted = "peer:deleted"
const TopicPeerUpdated = "peer:updated"
const TopicPeerInterfaceUpdated = "peer:interface:updated"
const TopicPeerIdentifierUpdated = "peer:identifier:updated"
const TopicPeerStateChanged = "peer:state:changed"
// endregion peer-events

View File

@ -71,7 +71,7 @@ func NewMailManager(
users UserDatabaseRepo,
wg WireguardDatabaseRepo,
) (*Manager, error) {
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl)
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle)
if err != nil {
return nil, fmt.Errorf("failed to initialize template handler: %w", err)
}

View File

@ -17,11 +17,12 @@ var TemplateFiles embed.FS
// TemplateHandler is a struct that holds the html and text templates.
type TemplateHandler struct {
portalUrl string
portalName string
htmlTemplates *htmlTemplate.Template
textTemplates *template.Template
}
func newTemplateHandler(portalUrl string) (*TemplateHandler, error) {
func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) {
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
if err != nil {
return nil, fmt.Errorf("failed to parse html template files: %w", err)
@ -34,6 +35,7 @@ func newTemplateHandler(portalUrl string) (*TemplateHandler, error) {
handler := &TemplateHandler{
portalUrl: portalUrl,
portalName: portalName,
htmlTemplates: htmlTemplateCache,
textTemplates: txtTemplateCache,
}
@ -81,6 +83,7 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName,
"ConfigFileName": cfgName,
"QrcodePngName": qrName,
"PortalUrl": c.portalUrl,
"PortalName": c.portalName,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gotpl: %w", err)
@ -91,6 +94,7 @@ func (c TemplateHandler) GetConfigMailWithAttachment(user *domain.User, cfgName,
"ConfigFileName": cfgName,
"QrcodePngName": qrName,
"PortalUrl": c.portalUrl,
"PortalName": c.portalName,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_attachment.gohtml: %w", err)

View File

@ -19,7 +19,7 @@
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
<!--<![endif]-->
<title>Email Template</title>
<title>{{$.PortalName}}</title>
<!--[if gte mso 9]>
<style type="text/css" media="all">
sup { font-size: 100% !important; }
@ -143,7 +143,7 @@
<td align="left">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="blue-button text-button" style="background:#000000; color:#c1cddc; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
<td class="blue-button text-button" style="background:#000000; color:#ffffff; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
</tr>
</table>
</td>
@ -167,10 +167,10 @@
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated using WireGuard Portal.</td>
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated by {{$.PortalName}}.</td>
</tr>
<tr>
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td>
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit {{$.PortalName}}</span></a></td>
</tr>
</table>
</td>

View File

@ -20,5 +20,5 @@ You can download and install the WireGuard VPN client from:
https://www.wireguard.com/install/
This mail was generated using WireGuard Portal.
This mail was generated by {{$.PortalName}}.
{{$.PortalUrl}}

View File

@ -19,7 +19,7 @@
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
<!--<![endif]-->
<title>Email Template</title>
<title>{{$.PortalName}}</title>
<!--[if gte mso 9]>
<style type="text/css" media="all">
sup { font-size: 100% !important; }
@ -143,7 +143,7 @@
<td align="left">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="blue-button text-button" style="background:#000000; color:#c1cddc; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
<td class="blue-button text-button" style="background:#000000; color:#ffffff; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download WireGuard VPN Client</span></a></td>
</tr>
</table>
</td>
@ -167,10 +167,10 @@
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#ffffff">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated using WireGuard Portal.</td>
<td class="text-footer1 pb10" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">This mail was generated by {{$.PortalName}}.</td>
</tr>
<tr>
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit WireGuard Portal</span></a></td>
<td class="text-footer2" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="{{$.PortalUrl}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:none;"><span class="link" style="color:#000000; text-decoration:none;">Visit {{$.PortalName}}</span></a></td>
</tr>
</table>
</td>

View File

@ -20,5 +20,5 @@ You can download and install the WireGuard VPN client from:
https://www.wireguard.com/install/
This mail was generated using WireGuard Portal.
This mail was generated by {{$.PortalName}}.
{{$.PortalUrl}}

View File

@ -25,6 +25,8 @@ type UserDatabaseRepo interface {
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
// GetUserByEmail returns the user with the given email address.
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential ID.
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
// GetAllUsers returns all users.
GetAllUsers(ctx context.Context) ([]domain.User, error)
// FindUsers returns all users matching the search string.
@ -129,6 +131,25 @@ func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User
return user, nil
}
// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential.
func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) {
user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64)
if err != nil {
return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err)
}
if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil {
return nil, err
}
peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case
user.LinkedPeerCount = len(peers)
return user, nil
}
// GetAllUsers returns all users.
func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
@ -343,6 +364,10 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
return errors.Join(fmt.Errorf("no access: %w", err), domain.ErrInvalidData)
}
if err := new.HasWeakPassword(m.cfg.Auth.MinPasswordLength); err != nil {
return errors.Join(fmt.Errorf("password too weak: %w", err), domain.ErrInvalidData)
}
if currentUser.Id == old.Identifier && old.IsAdmin && !new.IsAdmin {
return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData)
}
@ -397,7 +422,11 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
// database users must have a password
if new.Source == domain.UserSourceDatabase && string(new.Password) == "" {
return fmt.Errorf("invalid password: %w", domain.ErrInvalidData)
return fmt.Errorf("missing password: %w", domain.ErrInvalidData)
}
if err := new.HasWeakPassword(m.cfg.Auth.MinPasswordLength); err != nil {
return errors.Join(fmt.Errorf("password too weak: %w", err), domain.ErrInvalidData)
}
return nil

View File

@ -64,6 +64,7 @@ func (m Manager) connectToMessageBus() {
_ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent)
_ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent)
_ = m.bus.Subscribe(app.TopicPeerDeleted, m.handlePeerDeleteEvent)
_ = m.bus.Subscribe(app.TopicPeerStateChanged, m.handlePeerStateChangeEvent)
_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreateEvent)
_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent)
@ -135,6 +136,14 @@ func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) {
m.handleGenericEvent(WebhookEventDelete, iface)
}
func (m Manager) handlePeerStateChangeEvent(peerStatus domain.PeerStatus) {
if peerStatus.IsConnected {
m.handleGenericEvent(WebhookEventConnect, peerStatus)
} else {
m.handleGenericEvent(WebhookEventDisconnect, peerStatus)
}
}
func (m Manager) handleGenericEvent(action WebhookEvent, payload any) {
eventData, err := m.createWebhookData(action, payload)
if err != nil {
@ -177,6 +186,9 @@ func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookDa
case domain.Interface:
d.Entity = WebhookEntityInterface
d.Identifier = string(v.Identifier)
case domain.PeerStatus:
d.Entity = WebhookEntityPeer
d.Identifier = string(v.PeerId)
default:
return nil, fmt.Errorf("unsupported payload type: %T", v)
}

View File

@ -45,4 +45,6 @@ const (
WebhookEventCreate WebhookEvent = "create"
WebhookEventUpdate WebhookEvent = "update"
WebhookEventDelete WebhookEvent = "delete"
WebhookEventConnect WebhookEvent = "connect"
WebhookEventDisconnect WebhookEvent = "disconnect"
)

View File

@ -43,6 +43,8 @@ type StatisticsMetricsServer interface {
type StatisticsEventBus interface {
// Subscribe subscribes to a topic
Subscribe(topic string, fn interface{}) error
// Publish sends a message to the message bus.
Publish(topic string, args ...any)
}
type StatisticsCollector struct {
@ -55,6 +57,8 @@ type StatisticsCollector struct {
db StatisticsDatabaseRepo
wg StatisticsInterfaceController
ms StatisticsMetricsServer
peerChangeEvent chan domain.PeerIdentifier
}
// NewStatisticsCollector creates a new statistics collector.
@ -171,8 +175,12 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
continue
}
for _, peer := range peers {
var connectionStateChanged bool
var newPeerStatus domain.PeerStatus
err = c.db.UpdatePeerStatus(ctx, peer.Identifier,
func(p *domain.PeerStatus) (*domain.PeerStatus, error) {
wasConnected := p.IsConnected
var lastHandshake *time.Time
if !peer.LastHandshake.IsZero() {
lastHandshake = &peer.LastHandshake
@ -186,6 +194,12 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
p.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server
p.Endpoint = peer.Endpoint
p.LastHandshake = lastHandshake
p.CalcConnected()
if wasConnected != p.IsConnected {
connectionStateChanged = true
newPeerStatus = *p // store new status for event publishing
}
// Update prometheus metrics
go c.updatePeerMetrics(ctx, *p)
@ -197,6 +211,11 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
} else {
slog.Debug("updated peer status", "peer", peer.Identifier)
}
if connectionStateChanged {
// publish event if connection state changed
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus)
}
}
}
}
@ -298,12 +317,17 @@ func (c *StatisticsCollector) enqueuePingChecks(ctx context.Context) {
func (c *StatisticsCollector) pingWorker(ctx context.Context) {
defer c.pingWaitGroup.Done()
for peer := range c.pingJobs {
var connectionStateChanged bool
var newPeerStatus domain.PeerStatus
peerPingable := c.isPeerPingable(ctx, peer)
slog.Debug("peer ping check completed", "peer", peer.Identifier, "pingable", peerPingable)
now := time.Now()
err := c.db.UpdatePeerStatus(ctx, peer.Identifier,
func(p *domain.PeerStatus) (*domain.PeerStatus, error) {
wasConnected := p.IsConnected
if peerPingable {
p.IsPingable = true
p.LastPing = &now
@ -311,6 +335,13 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
p.IsPingable = false
p.LastPing = nil
}
p.UpdatedAt = time.Now()
p.CalcConnected()
if wasConnected != p.IsConnected {
connectionStateChanged = true
newPeerStatus = *p // store new status for event publishing
}
// Update prometheus metrics
go c.updatePeerMetrics(ctx, *p)
@ -322,6 +353,11 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) {
} else {
slog.Debug("updated peer ping status", "peer", peer.Identifier)
}
if connectionStateChanged {
// publish event if connection state changed
c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus)
}
}
}

View File

@ -188,6 +188,30 @@ func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Pee
sessionUser := domain.GetUserInfo(ctx)
// Enforce peer limit for non-admin users if LimitAdditionalUserPeers is set
if m.cfg.Core.SelfProvisioningAllowed && !sessionUser.IsAdmin && m.cfg.Advanced.LimitAdditionalUserPeers > 0 {
peers, err := m.db.GetUserPeers(ctx, peer.UserIdentifier)
if err != nil {
return nil, fmt.Errorf("failed to fetch peers for user %s: %w", peer.UserIdentifier, err)
}
// Count enabled peers (disabled IS NULL)
peerCount := 0
for _, p := range peers {
if !p.IsDisabled() {
peerCount++
}
}
totalAllowedPeers := 1 + m.cfg.Advanced.LimitAdditionalUserPeers // 1 default peer + x additional peers
if peerCount >= totalAllowedPeers {
slog.WarnContext(ctx, "peer creation blocked due to limit",
"user", peer.UserIdentifier,
"current_count", peerCount,
"allowed_count", totalAllowedPeers)
return nil, fmt.Errorf("peer limit reached (%d peers allowed): %w", totalAllowedPeers, domain.ErrNoPermission)
}
}
existingPeer, err := m.db.GetPeer(ctx, peer.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)

View File

@ -16,6 +16,14 @@ type Auth struct {
OAuth []OAuthProvider `yaml:"oauth"`
// Ldap contains a list of LDAP providers.
Ldap []LdapProvider `yaml:"ldap"`
// Webauthn contains the configuration for the WebAuthn authenticator.
WebAuthn WebauthnConfig `yaml:"webauthn"`
// MinPasswordLength is the minimum password length for user accounts. This also applies to the admin user.
// It is encouraged to set this value to at least 16 characters.
MinPasswordLength int `yaml:"min_password_length"`
// HideLoginForm specifies whether the login form should be hidden. If no social login providers are configured,
// the login form will be shown regardless of this setting.
HideLoginForm bool `yaml:"hide_login_form"`
}
// BaseFields contains the basic fields that are used to map user information from the authentication providers.
@ -245,3 +253,9 @@ type OAuthProvider struct {
// If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"`
}
// WebauthnConfig contains the configuration for the WebAuthn authenticator.
type WebauthnConfig struct {
// Enabled specifies whether WebAuthn is enabled.
Enabled bool `yaml:"enabled"`
}

View File

@ -41,6 +41,7 @@ type Config struct {
RulePrioOffset int `yaml:"rule_prio_offset"`
RouteTableOffset int `yaml:"route_table_offset"`
ApiAdminOnly bool `yaml:"api_admin_only"` // if true, only admin users can access the API
LimitAdditionalUserPeers int `yaml:"limit_additional_user_peers"`
} `yaml:"advanced"`
Statistics struct {
@ -76,6 +77,7 @@ func (c *Config) LogStartupValues() {
"reEnablePeerAfterUserEnable", c.Core.ReEnablePeerAfterUserEnable,
"deletePeerAfterUserDeleted", c.Core.DeletePeerAfterUserDeleted,
"selfProvisioningAllowed", c.Core.SelfProvisioningAllowed,
"limitAdditionalUserPeers", c.Advanced.LimitAdditionalUserPeers,
"importExisting", c.Core.ImportExisting,
"restoreState", c.Core.RestoreState,
"useIpV6", c.Advanced.UseIpV6,
@ -93,6 +95,9 @@ func (c *Config) LogStartupValues() {
"oidcProviders", len(c.Auth.OpenIDConnect),
"oauthProviders", len(c.Auth.OAuth),
"ldapProviders", len(c.Auth.Ldap),
"webauthnEnabled", c.Auth.WebAuthn.Enabled,
"minPasswordLength", c.Auth.MinPasswordLength,
"hideLoginForm", c.Auth.HideLoginForm,
)
}
@ -101,7 +106,7 @@ func defaultConfig() *Config {
cfg := &Config{}
cfg.Core.AdminUser = "admin@wgportal.local"
cfg.Core.AdminPassword = "wgportal"
cfg.Core.AdminPassword = "wgportal-default"
cfg.Core.AdminApiToken = "" // by default, the API access is disabled
cfg.Core.ImportExisting = true
cfg.Core.RestoreState = true
@ -137,6 +142,7 @@ func defaultConfig() *Config {
cfg.Advanced.RulePrioOffset = 20000
cfg.Advanced.RouteTableOffset = 20000
cfg.Advanced.ApiAdminOnly = true
cfg.Advanced.LimitAdditionalUserPeers = 0
cfg.Statistics.UsePingChecks = true
cfg.Statistics.PingCheckWorkers = 10
@ -164,6 +170,10 @@ func defaultConfig() *Config {
cfg.Webhook.Authentication = ""
cfg.Webhook.Timeout = 10 * time.Second
cfg.Auth.WebAuthn.Enabled = true
cfg.Auth.MinPasswordLength = 16
cfg.Auth.HideLoginForm = false
return cfg
}

View File

@ -3,21 +3,23 @@ package domain
import "time"
type PeerStatus struct {
PeerId PeerIdentifier `gorm:"primaryKey;column:identifier"`
UpdatedAt time.Time `gorm:"column:updated_at"`
PeerId PeerIdentifier `gorm:"primaryKey;column:identifier" json:"PeerId"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"-"`
IsPingable bool `gorm:"column:pingable"`
LastPing *time.Time `gorm:"column:last_ping"`
IsConnected bool `gorm:"column:connected" json:"IsConnected"` // indicates if the peer is connected based on the last handshake or ping
BytesReceived uint64 `gorm:"column:received"`
BytesTransmitted uint64 `gorm:"column:transmitted"`
IsPingable bool `gorm:"column:pingable" json:"IsPingable"`
LastPing *time.Time `gorm:"column:last_ping" json:"LastPing"`
LastHandshake *time.Time `gorm:"column:last_handshake"`
Endpoint string `gorm:"column:endpoint"`
LastSessionStart *time.Time `gorm:"column:last_session_start"`
BytesReceived uint64 `gorm:"column:received" json:"BytesReceived"`
BytesTransmitted uint64 `gorm:"column:transmitted" json:"BytesTransmitted"`
LastHandshake *time.Time `gorm:"column:last_handshake" json:"LastHandshake"`
Endpoint string `gorm:"column:endpoint" json:"Endpoint"`
LastSessionStart *time.Time `gorm:"column:last_session_start" json:"LastSessionStart"`
}
func (s PeerStatus) IsConnected() bool {
func (s *PeerStatus) CalcConnected() {
oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected
handshakeValid := false
@ -25,7 +27,7 @@ func (s PeerStatus) IsConnected() bool {
handshakeValid = !s.LastHandshake.Before(oldestHandshakeTime)
}
return s.IsPingable || handshakeValid
s.IsConnected = s.IsPingable || handshakeValid
}
type InterfaceStatus struct {

View File

@ -66,8 +66,9 @@ func TestPeerStatus_IsConnected(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.status.IsConnected(); got != tt.want {
t.Errorf("IsConnected() = %v, want %v", got, tt.want)
tt.status.CalcConnected()
if got := tt.status.IsConnected; got != tt.want {
t.Errorf("IsConnected = %v, want %v", got, tt.want)
}
})
}

View File

@ -2,9 +2,16 @@ package domain
import (
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
@ -43,6 +50,10 @@ type User struct {
Locked *time.Time `gorm:"index;column:locked"` // if this field is set, the user is locked and can no longer login (WireGuard peers still can connect)
LockedReason string // the reason why the user has been locked
// Passwordless authentication
WebAuthnId string `gorm:"column:webauthn_id"` // the webauthn id of the user, used for webauthn authentication
WebAuthnCredentialList []UserWebauthnCredential `gorm:"foreignKey:user_identifier"` // the webauthn credentials of the user, used for webauthn authentication
// API token for REST API access
ApiToken string `form:"api_token" binding:"omitempty"`
ApiTokenCreated *time.Time
@ -77,6 +88,22 @@ func (u *User) CanChangePassword() error {
return errors.New("password change only allowed for database source")
}
func (u *User) HasWeakPassword(minLength int) error {
if u.Source != UserSourceDatabase {
return nil // password is not required for non-database users, so no check needed
}
if u.Password == "" {
return nil // password is not set, so no check needed
}
if len(u.Password) < minLength {
return fmt.Errorf("password is too short, minimum length is %d", minLength)
}
return nil // password is strong enough
}
func (u *User) EditAllowed(new *User) error {
if u.Source == UserSourceDatabase {
return nil
@ -157,3 +184,148 @@ func (u *User) CopyCalculatedAttributes(src *User) {
u.BaseModel = src.BaseModel
u.LinkedPeerCount = src.LinkedPeerCount
}
// region webauthn
func (u *User) WebAuthnID() []byte {
decodeString, err := base64.StdEncoding.DecodeString(u.WebAuthnId)
if err != nil {
return nil
}
return decodeString
}
func (u *User) GenerateWebAuthnId() {
randomUid1 := uuid.New().String() // 32 hex digits + 4 dashes
randomUid2 := uuid.New().String() // 32 hex digits + 4 dashes
webAuthnId := []byte(strings.ReplaceAll(fmt.Sprintf("%s%s", randomUid1, randomUid2), "-", "")) // 64 hex digits
u.WebAuthnId = base64.StdEncoding.EncodeToString(webAuthnId)
}
func (u *User) WebAuthnName() string {
return string(u.Identifier)
}
func (u *User) WebAuthnDisplayName() string {
var userName string
switch {
case u.Firstname != "" && u.Lastname != "":
userName = fmt.Sprintf("%s %s", u.Firstname, u.Lastname)
case u.Firstname != "":
userName = u.Firstname
case u.Lastname != "":
userName = u.Lastname
default:
userName = string(u.Identifier)
}
return userName
}
func (u *User) WebAuthnCredentials() []webauthn.Credential {
credentials := make([]webauthn.Credential, len(u.WebAuthnCredentialList))
for i, cred := range u.WebAuthnCredentialList {
credential, err := cred.GetCredential()
if err != nil {
continue
}
credentials[i] = credential
}
return credentials
}
func (u *User) AddCredential(userId UserIdentifier, name string, credential webauthn.Credential) error {
cred, err := NewUserWebauthnCredential(userId, name, credential)
if err != nil {
return err
}
// Check if the credential already exists
for _, c := range u.WebAuthnCredentialList {
if c.GetCredentialId() == string(credential.ID) {
return errors.New("credential already exists")
}
}
u.WebAuthnCredentialList = append(u.WebAuthnCredentialList, cred)
return nil
}
func (u *User) UpdateCredential(credentialIdBase64, name string) error {
for i, c := range u.WebAuthnCredentialList {
if c.CredentialIdentifier == credentialIdBase64 {
u.WebAuthnCredentialList[i].DisplayName = name
return nil
}
}
return errors.New("credential not found")
}
func (u *User) RemoveCredential(credentialIdBase64 string) {
u.WebAuthnCredentialList = slices.DeleteFunc(u.WebAuthnCredentialList, func(e UserWebauthnCredential) bool {
return e.CredentialIdentifier == credentialIdBase64
})
}
type UserWebauthnCredential struct {
UserIdentifier string `gorm:"primaryKey;column:user_identifier"` // the user identifier
CredentialIdentifier string `gorm:"primaryKey;uniqueIndex;column:credential_identifier"` // base64 encoded credential id
CreatedAt time.Time `gorm:"column:created_at"` // the time when the credential was created
DisplayName string `gorm:"column:display_name"` // the display name of the credential
SerializedCredential string `gorm:"column:serialized_credential"` // JSON and base64 encoded credential
}
func NewUserWebauthnCredential(userIdentifier UserIdentifier, name string, credential webauthn.Credential) (
UserWebauthnCredential,
error,
) {
c := UserWebauthnCredential{
UserIdentifier: string(userIdentifier),
CreatedAt: time.Now(),
DisplayName: name,
CredentialIdentifier: base64.StdEncoding.EncodeToString(credential.ID),
}
err := c.SetCredential(credential)
if err != nil {
return c, err
}
return c, nil
}
func (c *UserWebauthnCredential) SetCredential(credential webauthn.Credential) error {
jsonData, err := json.Marshal(credential)
if err != nil {
return fmt.Errorf("failed to marshal credential: %w", err)
}
c.SerializedCredential = base64.StdEncoding.EncodeToString(jsonData)
return nil
}
func (c *UserWebauthnCredential) GetCredential() (webauthn.Credential, error) {
jsonData, err := base64.StdEncoding.DecodeString(c.SerializedCredential)
if err != nil {
return webauthn.Credential{}, fmt.Errorf("failed to decode base64 credential: %w", err)
}
var credential webauthn.Credential
if err := json.Unmarshal(jsonData, &credential); err != nil {
return webauthn.Credential{}, fmt.Errorf("failed to unmarshal credential: %w", err)
}
return credential, nil
}
func (c *UserWebauthnCredential) GetCredentialId() string {
decodeString, _ := base64.StdEncoding.DecodeString(c.CredentialIdentifier)
return string(decodeString)
}
// endregion webauthn

View File

@ -78,6 +78,11 @@ nav:
- Configuration:
- Overview: documentation/configuration/overview.md
- Examples: documentation/configuration/examples.md
- Usage:
- General: documentation/usage/general.md
- LDAP: documentation/usage/ldap.md
- Security: documentation/usage/security.md
- Webhooks: documentation/usage/webhooks.md
- REST API: documentation/rest-api/api-doc.md
- Upgrade: documentation/upgrade/v1.md
- Monitoring: documentation/monitoring/prometheus.md
- REST API: documentation/rest-api/api-doc.md