mirror of
https://github.com/h44z/wg-portal.git
synced 2025-06-28 01:07:03 +00:00
Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
dd28a8dddf | ||
|
f994700caf | ||
|
be29abd29a | ||
|
94785c10ec | ||
|
3a732fd3e5 | ||
|
f0be66aea4 | ||
|
cbf8c5bca9 | ||
|
b6bfa1f6de | ||
|
0c8d6223ce | ||
|
e3b65ca337 | ||
|
61d8aa6589 | ||
|
7fd2bbad02 | ||
|
75a5f3d815 | ||
|
e9005b1b90 | ||
|
8816165260 | ||
|
ab9995350f | ||
|
1394be2341 |
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@ -28,3 +28,8 @@ updates:
|
||||
patch:
|
||||
update-types:
|
||||
- patch
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
BIN
docs/assets/images/interface_view.png
Normal file
BIN
docs/assets/images/interface_view.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 134 KiB |
BIN
docs/assets/images/landing_page.png
Normal file
BIN
docs/assets/images/landing_page.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 110 KiB |
BIN
docs/assets/images/passkey_setup.png
Normal file
BIN
docs/assets/images/passkey_setup.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 106 KiB |
@ -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
|
||||
|
@ -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.
|
||||
|
57
docs/documentation/usage/general.md
Normal file
57
docs/documentation/usage/general.md
Normal 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
|
||||
|
||||

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

|
||||
|
||||
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.
|
37
docs/documentation/usage/ldap.md
Normal file
37
docs/documentation/usage/ldap.md
Normal 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.
|
160
docs/documentation/usage/security.md
Normal file
160
docs/documentation/usage/security.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
|
||||
### 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.
|
86
docs/documentation/usage/webhooks.md
Normal file
86
docs/documentation/usage/webhooks.md
Normal 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"
|
||||
}
|
||||
}
|
||||
```
|
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -61,6 +61,26 @@ const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
|
||||
const wgVersion = ref(WGPORTAL_VERSION);
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
|
||||
const userDisplayName = computed(() => {
|
||||
let displayName = "Unknown";
|
||||
if (auth.IsAuthenticated) {
|
||||
if (auth.User.Firstname === "" && auth.User.Lastname === "") {
|
||||
displayName = auth.User.Identifier;
|
||||
} else if (auth.User.Firstname === "" && auth.User.Lastname !== "") {
|
||||
displayName = auth.User.Lastname;
|
||||
} else if (auth.User.Firstname !== "" && auth.User.Lastname === "") {
|
||||
displayName = auth.User.Firstname;
|
||||
} else if (auth.User.Firstname !== "" && auth.User.Lastname !== "") {
|
||||
displayName = auth.User.Firstname + " " + auth.User.Lastname;
|
||||
}
|
||||
}
|
||||
|
||||
// pad string to 20 characters so that the menu is always the same size on desktop
|
||||
if (displayName.length < 20 && window.innerWidth > 992) {
|
||||
displayName = displayName.padStart(20, "\u00A0");
|
||||
}
|
||||
return displayName;
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -93,10 +113,10 @@ const currentYear = ref(new Date().getFullYear())
|
||||
<div class="navbar-nav d-flex justify-content-end">
|
||||
<div v-if="auth.IsAuthenticated" class="nav-item dropdown">
|
||||
<a aria-expanded="false" aria-haspopup="true" class="nav-link dropdown-toggle" data-bs-toggle="dropdown"
|
||||
href="#" role="button">{{ auth.User.Firstname }} {{ auth.User.Lastname }}</a>
|
||||
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>
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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,17 +120,46 @@ 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>
|
||||
<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"
|
||||
v-html="provider.Name" @click.prevent="externalLogin(provider)"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -104,7 +168,6 @@ const externalLogin = function (provider) {
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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
25
go.mod
@ -10,6 +10,7 @@ require (
|
||||
github.com/go-ldap/ldap/v3 v3.4.11
|
||||
github.com/go-pkgz/routegroup v1.4.1
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
github.com/go-webauthn/webauthn v0.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
165
go.sum
@ -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=
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -57,6 +57,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login/{provider}/callback": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Handle the OAuth callback.",
|
||||
"operationId": "auth_handleOauthCallbackGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.LoginProviderInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login/{provider}/init": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Initiate the OAuth login flow.",
|
||||
"operationId": "auth_handleOauthInitiateGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.LoginProviderInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/logout": {
|
||||
"post": {
|
||||
"produces": [
|
||||
@ -129,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"
|
||||
@ -160,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2085,14 +2231,23 @@
|
||||
"ApiAdminOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"LoginFormVisible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"MailLinkOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"MinPasswordLength": {
|
||||
"type": "integer"
|
||||
},
|
||||
"PersistentConfigSupported": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"SelfProvisioning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"WebAuthnEnabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -2161,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -381,12 +381,18 @@ definitions:
|
||||
properties:
|
||||
ApiAdminOnly:
|
||||
type: boolean
|
||||
LoginFormVisible:
|
||||
type: boolean
|
||||
MailLinkOnly:
|
||||
type: boolean
|
||||
MinPasswordLength:
|
||||
type: integer
|
||||
PersistentConfigSupported:
|
||||
type: boolean
|
||||
SelfProvisioning:
|
||||
type: boolean
|
||||
WebAuthnEnabled:
|
||||
type: boolean
|
||||
type: object
|
||||
model.User:
|
||||
properties:
|
||||
@ -433,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
|
||||
@ -456,7 +476,22 @@ paths:
|
||||
summary: Get all available audit entries. Ordered by timestamp.
|
||||
tags:
|
||||
- Audit
|
||||
/auth/{provider}/callback:
|
||||
/auth/login:
|
||||
post:
|
||||
operationId: auth_handleLoginPost
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.LoginProviderInfo'
|
||||
type: array
|
||||
summary: Get all available external login providers.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/login/{provider}/callback:
|
||||
get:
|
||||
operationId: auth_handleOauthCallbackGet
|
||||
produces:
|
||||
@ -471,7 +506,7 @@ paths:
|
||||
summary: Handle the OAuth callback.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/{provider}/init:
|
||||
/auth/login/{provider}/init:
|
||||
get:
|
||||
operationId: auth_handleOauthInitiateGet
|
||||
produces:
|
||||
@ -486,21 +521,6 @@ paths:
|
||||
summary: Initiate the OAuth login flow.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/login:
|
||||
post:
|
||||
operationId: auth_handleLoginPost
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.LoginProviderInfo'
|
||||
type: array
|
||||
summary: Get all available external login providers.
|
||||
tags:
|
||||
- Authentication
|
||||
/auth/logout:
|
||||
post:
|
||||
operationId: auth_handleLogoutPost
|
||||
@ -548,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
|
||||
|
@ -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 {
|
||||
|
@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@ -28,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(
|
||||
@ -42,6 +85,7 @@ func NewAuthEndpoint(
|
||||
session Session,
|
||||
validator Validator,
|
||||
authService AuthenticationService,
|
||||
webAuthn WebAuthnService,
|
||||
) AuthEndpoint {
|
||||
return AuthEndpoint{
|
||||
cfg: cfg,
|
||||
@ -49,6 +93,7 @@ func NewAuthEndpoint(
|
||||
authenticator: authenticator,
|
||||
session: session,
|
||||
validate: validator,
|
||||
webAuthn: webAuthn,
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,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())
|
||||
}
|
||||
@ -132,7 +190,7 @@ func (e AuthEndpoint) handleSessionInfoGet() http.HandlerFunc {
|
||||
// @Summary Initiate the OAuth login flow.
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.LoginProviderInfo
|
||||
// @Router /auth/{provider}/init [get]
|
||||
// @Router /auth/login/{provider}/init [get]
|
||||
func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
@ -177,6 +235,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
|
||||
|
||||
authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider)
|
||||
if err != nil {
|
||||
slog.Debug("failed to create oauth auth code URL",
|
||||
"provider", provider, "error", err)
|
||||
if autoRedirect && e.isValidReturnUrl(returnTo) {
|
||||
redirectToReturn()
|
||||
} else {
|
||||
@ -211,7 +271,7 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
|
||||
// @Summary Handle the OAuth callback.
|
||||
// @Produce json
|
||||
// @Success 200 {object} []model.LoginProviderInfo
|
||||
// @Router /auth/{provider}/callback [get]
|
||||
// @Router /auth/login/{provider}/callback [get]
|
||||
func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
currentSession := e.session.GetData(r.Context())
|
||||
@ -249,6 +309,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
|
||||
oauthState := request.Query(r, "state")
|
||||
|
||||
if provider != currentSession.OauthProvider {
|
||||
slog.Debug("invalid oauth provider in callback",
|
||||
"expected", currentSession.OauthProvider, "got", provider, "state", oauthState)
|
||||
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
|
||||
redirectToReturn()
|
||||
} else {
|
||||
@ -258,6 +320,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if oauthState != currentSession.OauthState {
|
||||
slog.Debug("invalid oauth state in callback",
|
||||
"expected", currentSession.OauthState, "got", oauthState, "provider", provider)
|
||||
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
|
||||
redirectToReturn()
|
||||
} else {
|
||||
@ -267,11 +331,13 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 1000*time.Second)
|
||||
loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits
|
||||
user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
|
||||
oauthCode)
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Debug("failed to process oauth code",
|
||||
"provider", provider, "state", oauthState, "error", err)
|
||||
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
|
||||
redirectToReturn()
|
||||
} else {
|
||||
@ -389,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))
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
respond.JSON(w, http.StatusOK, model.Settings{
|
||||
MailLinkOnly: e.cfg.Mail.LinkOnly,
|
||||
PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
|
||||
SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
|
||||
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
|
||||
})
|
||||
sessionUser := domain.GetUserInfo(r.Context())
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -31,6 +31,8 @@ type SessionData struct {
|
||||
OauthProvider string
|
||||
OauthReturnTo string
|
||||
|
||||
WebAuthnData string
|
||||
|
||||
CsrfToken string
|
||||
}
|
||||
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
301
internal/app/auth/webauthn.go
Normal file
301
internal/app/auth/webauthn.go
Normal file
@ -0,0 +1,301 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
"github.com/h44z/wg-portal/internal/app/audit"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
type WebAuthnUserManager interface {
|
||||
// GetUser returns a user by its identifier.
|
||||
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
|
||||
// GetUserByWebAuthnCredential returns a user by its WebAuthn ID.
|
||||
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
|
||||
// UpdateUser updates an existing user in the database.
|
||||
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
|
||||
}
|
||||
|
||||
type WebAuthnAuthenticator struct {
|
||||
webAuthn *webauthn.WebAuthn
|
||||
users WebAuthnUserManager
|
||||
bus EventBus
|
||||
}
|
||||
|
||||
func NewWebAuthnAuthenticator(cfg *config.Config, bus EventBus, users WebAuthnUserManager) (
|
||||
*WebAuthnAuthenticator,
|
||||
error,
|
||||
) {
|
||||
if !cfg.Auth.WebAuthn.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
extUrl, err := url.Parse(cfg.Web.ExternalUrl)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to parse external URL - required for WebAuthn RP ID")
|
||||
}
|
||||
|
||||
rpId := extUrl.Hostname()
|
||||
if rpId == "" {
|
||||
return nil, errors.New("failed to determine Webauthn RPID")
|
||||
}
|
||||
|
||||
// Initialize the WebAuthn authenticator with the provided configuration
|
||||
awCfg := &webauthn.Config{
|
||||
RPID: rpId,
|
||||
RPDisplayName: cfg.Web.SiteTitle,
|
||||
RPOrigins: []string{cfg.Web.ExternalUrl},
|
||||
}
|
||||
|
||||
webAuthn, err := webauthn.New(awCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Webauthn instance: %w", err)
|
||||
}
|
||||
|
||||
return &WebAuthnAuthenticator{
|
||||
webAuthn: webAuthn,
|
||||
users: users,
|
||||
bus: bus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *WebAuthnAuthenticator) Enabled() bool {
|
||||
return a != nil && a.webAuthn != nil
|
||||
}
|
||||
|
||||
func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
|
||||
optionsAsJSON []byte,
|
||||
sessionDataAsJSON []byte,
|
||||
err error,
|
||||
) {
|
||||
user, err := a.users.GetUser(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
if user.IsLocked() || user.IsDisabled() {
|
||||
return nil, nil, errors.New("user is locked") // adding passkey to locked user is not allowed
|
||||
}
|
||||
|
||||
if user.WebAuthnId == "" {
|
||||
user.GenerateWebAuthnId()
|
||||
user, err = a.users.UpdateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
options, sessionData, err := a.webAuthn.BeginRegistration(user,
|
||||
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
|
||||
}
|
||||
|
||||
optionsAsJSON, err = json.Marshal(options)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
|
||||
}
|
||||
sessionDataAsJSON, err = json.Marshal(sessionData)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
|
||||
}
|
||||
|
||||
return optionsAsJSON, sessionDataAsJSON, nil
|
||||
}
|
||||
|
||||
func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
name string,
|
||||
sessionDataAsJSON []byte,
|
||||
r *http.Request,
|
||||
) ([]domain.UserWebauthnCredential, error) {
|
||||
user, err := a.users.GetUser(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
if user.IsLocked() || user.IsDisabled() {
|
||||
return nil, errors.New("user is locked") // adding passkey to locked user is not allowed
|
||||
}
|
||||
|
||||
var webAuthnData webauthn.SessionData
|
||||
err = json.Unmarshal(sessionDataAsJSON, &webAuthnData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
|
||||
}
|
||||
|
||||
credential, err := a.webAuthn.FinishRegistration(user, webAuthnData, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Passkey %d", len(user.WebAuthnCredentialList)+1) // fallback name
|
||||
}
|
||||
|
||||
// Add the credential to the user
|
||||
err = user.AddCredential(userId, name, *credential)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err = a.users.UpdateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user.WebAuthnCredentialList, nil
|
||||
}
|
||||
|
||||
func (a *WebAuthnAuthenticator) GetCredentials(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
) ([]domain.UserWebauthnCredential, error) {
|
||||
user, err := a.users.GetUser(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
return user.WebAuthnCredentialList, nil
|
||||
}
|
||||
|
||||
func (a *WebAuthnAuthenticator) RemoveCredential(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
credentialIdBase64 string,
|
||||
) ([]domain.UserWebauthnCredential, error) {
|
||||
user, err := a.users.GetUser(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
user.RemoveCredential(credentialIdBase64)
|
||||
user, err = a.users.UpdateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user.WebAuthnCredentialList, nil
|
||||
}
|
||||
|
||||
func (a *WebAuthnAuthenticator) UpdateCredential(
|
||||
ctx context.Context,
|
||||
userId domain.UserIdentifier,
|
||||
credentialIdBase64 string,
|
||||
name string,
|
||||
) ([]domain.UserWebauthnCredential, error) {
|
||||
user, err := a.users.GetUser(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
err = user.UpdateCredential(credentialIdBase64, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err = a.users.UpdateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user.WebAuthnCredentialList, nil
|
||||
}
|
||||
|
||||
func (a *WebAuthnAuthenticator) StartWebAuthnLogin(_ context.Context) (
|
||||
optionsAsJSON []byte,
|
||||
sessionDataAsJSON []byte,
|
||||
err error,
|
||||
) {
|
||||
options, sessionData, err := a.webAuthn.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to begin WebAuthn login: %w", err)
|
||||
}
|
||||
|
||||
optionsAsJSON, err = json.Marshal(options)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
|
||||
}
|
||||
sessionDataAsJSON, err = json.Marshal(sessionData)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
|
||||
}
|
||||
|
||||
return optionsAsJSON, sessionDataAsJSON, nil
|
||||
}
|
||||
|
||||
func (a *WebAuthnAuthenticator) FinishWebAuthnLogin(
|
||||
ctx context.Context,
|
||||
sessionDataAsJSON []byte,
|
||||
r *http.Request,
|
||||
) (*domain.User, error) {
|
||||
|
||||
var webAuthnData webauthn.SessionData
|
||||
err := json.Unmarshal(sessionDataAsJSON, &webAuthnData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
|
||||
}
|
||||
|
||||
// switch to admin context for user lookup
|
||||
ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo())
|
||||
|
||||
credential, err := a.webAuthn.FinishDiscoverableLogin(a.findUserForWebAuthnSecretFn(ctx), webAuthnData, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find the user by the WebAuthn ID
|
||||
user, err := a.users.GetUserByWebAuthnCredential(ctx,
|
||||
base64.StdEncoding.EncodeToString(credential.ID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
|
||||
}
|
||||
|
||||
if user.IsLocked() || user.IsDisabled() {
|
||||
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
|
||||
Ctx: ctx,
|
||||
Source: "passkey",
|
||||
Event: audit.AuthEvent{
|
||||
Username: string(user.Identifier), Error: "User is locked",
|
||||
},
|
||||
})
|
||||
return nil, errors.New("user is locked") // login with passkey is not allowed
|
||||
}
|
||||
|
||||
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
|
||||
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
|
||||
Ctx: ctx,
|
||||
Source: "passkey",
|
||||
Event: audit.AuthEvent{
|
||||
Username: string(user.Identifier),
|
||||
},
|
||||
})
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *WebAuthnAuthenticator) findUserForWebAuthnSecretFn(ctx context.Context) func(rawID, userHandle []byte) (
|
||||
user webauthn.User,
|
||||
err error,
|
||||
) {
|
||||
return func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
// Find the user by the WebAuthn ID
|
||||
user, err := a.users.GetUserByWebAuthnCredential(ctx, base64.StdEncoding.EncodeToString(rawID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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}}
|
@ -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>
|
||||
|
@ -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}}
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -42,7 +42,9 @@ const (
|
||||
type WebhookEvent = string
|
||||
|
||||
const (
|
||||
WebhookEventCreate WebhookEvent = "create"
|
||||
WebhookEventUpdate WebhookEvent = "update"
|
||||
WebhookEventDelete WebhookEvent = "delete"
|
||||
WebhookEventCreate WebhookEvent = "create"
|
||||
WebhookEventUpdate WebhookEvent = "update"
|
||||
WebhookEventDelete WebhookEvent = "delete"
|
||||
WebhookEventConnect WebhookEvent = "connect"
|
||||
WebhookEventDisconnect WebhookEvent = "disconnect"
|
||||
)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@ package wireguard
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
@ -76,6 +77,8 @@ type Manager struct {
|
||||
db InterfaceAndPeerDatabaseRepo
|
||||
wg InterfaceController
|
||||
quick WgQuickController
|
||||
|
||||
userLockMap *sync.Map
|
||||
}
|
||||
|
||||
func NewWireGuardManager(
|
||||
@ -86,11 +89,12 @@ func NewWireGuardManager(
|
||||
db InterfaceAndPeerDatabaseRepo,
|
||||
) (*Manager, error) {
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
bus: bus,
|
||||
wg: wg,
|
||||
db: db,
|
||||
quick: quick,
|
||||
cfg: cfg,
|
||||
bus: bus,
|
||||
wg: wg,
|
||||
db: db,
|
||||
quick: quick,
|
||||
userLockMap: &sync.Map{},
|
||||
}
|
||||
|
||||
m.connectToMessageBus()
|
||||
@ -117,6 +121,12 @@ func (m Manager) handleUserCreationEvent(user domain.User) {
|
||||
return
|
||||
}
|
||||
|
||||
_, loaded := m.userLockMap.LoadOrStore(user.Identifier, "create")
|
||||
if loaded {
|
||||
return // another goroutine is already handling this user
|
||||
}
|
||||
defer m.userLockMap.Delete(user.Identifier)
|
||||
|
||||
slog.Debug("handling new user event", "user", user.Identifier)
|
||||
|
||||
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
|
||||
@ -132,6 +142,12 @@ func (m Manager) handleUserLoginEvent(userId domain.UserIdentifier) {
|
||||
return
|
||||
}
|
||||
|
||||
_, loaded := m.userLockMap.LoadOrStore(userId, "login")
|
||||
if loaded {
|
||||
return // another goroutine is already handling this user
|
||||
}
|
||||
defer m.userLockMap.Delete(userId)
|
||||
|
||||
userPeers, err := m.db.GetUserPeers(context.Background(), userId)
|
||||
if err != nil {
|
||||
slog.Error("failed to retrieve existing peers prior to default peer creation",
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
@ -23,12 +24,24 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
|
||||
return fmt.Errorf("failed to fetch all interfaces: %w", err)
|
||||
}
|
||||
|
||||
userPeers, err := m.db.GetUserPeers(context.Background(), userId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve existing peers prior to default peer creation: %w", err)
|
||||
}
|
||||
|
||||
var newPeers []domain.Peer
|
||||
for _, iface := range existingInterfaces {
|
||||
if iface.Type != domain.InterfaceTypeServer {
|
||||
continue // only create default peers for server interfaces
|
||||
}
|
||||
|
||||
peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool {
|
||||
return peer.InterfaceIdentifier == iface.Identifier
|
||||
})
|
||||
if peerAlreadyCreated {
|
||||
continue // skip creation if a peer already exists for this interface
|
||||
}
|
||||
|
||||
peer, err := m.PreparePeer(ctx, iface.Identifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create default peer for interface %s: %w", iface.Identifier, err)
|
||||
@ -175,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)
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -29,18 +29,19 @@ type Config struct {
|
||||
} `yaml:"core"`
|
||||
|
||||
Advanced struct {
|
||||
LogLevel string `yaml:"log_level"`
|
||||
LogPretty bool `yaml:"log_pretty"`
|
||||
LogJson bool `yaml:"log_json"`
|
||||
StartListenPort int `yaml:"start_listen_port"`
|
||||
StartCidrV4 string `yaml:"start_cidr_v4"`
|
||||
StartCidrV6 string `yaml:"start_cidr_v6"`
|
||||
UseIpV6 bool `yaml:"use_ip_v6"`
|
||||
ConfigStoragePath string `yaml:"config_storage_path"` // keep empty to disable config export to file
|
||||
ExpiryCheckInterval time.Duration `yaml:"expiry_check_interval"`
|
||||
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
|
||||
LogLevel string `yaml:"log_level"`
|
||||
LogPretty bool `yaml:"log_pretty"`
|
||||
LogJson bool `yaml:"log_json"`
|
||||
StartListenPort int `yaml:"start_listen_port"`
|
||||
StartCidrV4 string `yaml:"start_cidr_v4"`
|
||||
StartCidrV6 string `yaml:"start_cidr_v6"`
|
||||
UseIpV6 bool `yaml:"use_ip_v6"`
|
||||
ConfigStoragePath string `yaml:"config_storage_path"` // keep empty to disable config export to file
|
||||
ExpiryCheckInterval time.Duration `yaml:"expiry_check_interval"`
|
||||
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
|
||||
}
|
||||
|
||||
@ -190,6 +200,8 @@ func GetConfig() (*Config, error) {
|
||||
return nil, fmt.Errorf("failed to load config from yaml: %w", err)
|
||||
}
|
||||
|
||||
cfg.Web.Sanitize()
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// WebConfig contains the configuration for the web server.
|
||||
type WebConfig struct {
|
||||
// RequestLogging enables logging of all HTTP requests.
|
||||
@ -26,3 +28,7 @@ type WebConfig struct {
|
||||
// KeyFile is the path to the TLS certificate key file.
|
||||
KeyFile string `yaml:"key_file"`
|
||||
}
|
||||
|
||||
func (c *WebConfig) Sanitize() {
|
||||
c.ExternalUrl = strings.TrimRight(c.ExternalUrl, "/")
|
||||
}
|
||||
|
@ -136,6 +136,7 @@ func (p *Peer) OverwriteUserEditableFields(userPeer *Peer, cfg *config.Config) {
|
||||
p.Interface.PublicKey = userPeer.Interface.PublicKey
|
||||
p.Interface.PrivateKey = userPeer.Interface.PrivateKey
|
||||
p.PresharedKey = userPeer.PresharedKey
|
||||
p.Identifier = userPeer.Identifier
|
||||
}
|
||||
p.Interface.Mtu = userPeer.Interface.Mtu
|
||||
p.PersistentKeepalive = userPeer.PersistentKeepalive
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user