diff --git a/README.md b/README.md index c5a2968..018b748 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index 456290c..dbd2020 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -88,6 +88,9 @@ func main() { authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager) internal.AssertNoError(err) + webAuthn, err := auth.NewWebAuthnAuthenticator(cfg, eventBus, userManager) + internal.AssertNoError(err) + wireGuardManager, err := wireguard.NewWireGuardManager(cfg, eventBus, wireGuard, wgQuick, database) internal.AssertNoError(err) wireGuardManager.StartBackgroundJobs(ctx) @@ -124,7 +127,8 @@ func main() { apiV0BackendInterfaces := backendV0.NewInterfaceService(cfg, wireGuardManager, cfgFileManager) apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager) - apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator) + apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator, + webAuthn) apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager) apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers) apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces) diff --git a/docs/assets/images/interface_view.png b/docs/assets/images/interface_view.png new file mode 100644 index 0000000..09f5b1b Binary files /dev/null and b/docs/assets/images/interface_view.png differ diff --git a/docs/assets/images/landing_page.png b/docs/assets/images/landing_page.png new file mode 100644 index 0000000..4bb72bb Binary files /dev/null and b/docs/assets/images/landing_page.png differ diff --git a/docs/assets/images/passkey_setup.png b/docs/assets/images/passkey_setup.png new file mode 100644 index 0000000..af39e89 Binary files /dev/null and b/docs/assets/images/passkey_setup.png differ diff --git a/docs/documentation/configuration/examples.md b/docs/documentation/configuration/examples.md index 1409d2b..41a4034 100644 --- a/docs/documentation/configuration/examples.md +++ b/docs/documentation/configuration/examples.md @@ -32,6 +32,10 @@ database: type: sqlite dsn: data/sqlite.db encryption_passphrase: change-this-s3cr3t-encryption-passphrase + +auth: + webauthn: + enabled: true ``` ## LDAP Authentication and Synchronization diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index bc47ba0..70496aa 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -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 @@ -72,6 +72,9 @@ auth: oidc: [] oauth: [] ldap: [] + webauthn: + enabled: true + min_password_length: 16 web: listening_address: :8888 @@ -118,8 +121,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)* @@ -334,9 +338,17 @@ 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. + --- ### OIDC @@ -540,6 +552,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 +594,16 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`: --- +### WebAuthn (Passkeys) + +The `webauthn` section contains configuration options for WebAuthn authentication (passkeys). + +#### `enabled` +- **Default:** `true` +- **Description:** If `true`, Passkey authentication is enabled. If `false`, WebAuthn is disabled. + Users are encouraged to use Passkeys for secure authentication instead of passwords. + If a passkey is registered, the password login is still available as a fallback. Ensure that the password is strong and secure. + ## Web The web section contains configuration options for the web server, including the listening address, session management, and CSRF protection. diff --git a/docs/documentation/usage/general.md b/docs/documentation/usage/general.md new file mode 100644 index 0000000..6ebf5be --- /dev/null +++ b/docs/documentation/usage/general.md @@ -0,0 +1,57 @@ +This documentation section describes the general usage of WireGuard Portal. +If you are looking for specific setup instructions, please refer to the *Getting Started* and [*Configuration*](../configuration/overview.md) sections, +for example, using a [Docker](../getting-started/docker.md) deployment. + +## Basic Concepts + +WireGuard Portal is a web-based configuration portal for WireGuard server management. It allows managing multiple WireGuard interfaces and users from a single web UI. +WireGuard Interfaces can be categorized into three types: + + - **Server**: A WireGuard server interface that to which multiple peers can connect. In this mode, it is possible to specify default settings for all peers, such as the IP address range, DNS servers, and MTU size. + - **Client**: A WireGuard client interface that can be used to connect to a WireGuard server. Usually, such an interface has exactly one peer. + - **Unknown**: This is the default type for imported interfaces. It is encouraged to change the type to either `Server` or `Client` after importing the interface. + +## Accessing the Web UI + +The web UI should be accessed via the URL specified in the `external_url` property of the configuration file. +By default, WireGuard Portal listens on port `8888` for HTTP connections. Check the [Security](security.md) section for more information on securing the web UI. + +So the default URL to access the web UI is: + +``` +http://localhost:8888 +``` + +A freshly set-up WireGuard Portal instance will have a default admin user with the username `admin@wgportal.local` and the password `wgportal-default`. +You can and should override the default credentials in the configuration file. Make sure to change the default password immediately after the first login! + + +### Basic UI Description + +![WireGuard Portal Web UI](../../assets/images/landing_page.png) + +As seen in the screenshot above, the web UI is divided into several sections which are accessible via the navigation bar on the top of the screen. + +1. **Home**: The landing page of WireGuard Portal. It provides a staring point for the user to access the different sections of the web UI. It also provides quick links to WireGuard Client downloads or official documentation. +2. **Interfaces**: This section allows you to manage the WireGuard interfaces. You can add, edit, or delete interfaces, as well as view their status and statistics. Peers for each interface can be managed here as well. +3. **Users**: This section allows you to manage the users of WireGuard Portal. You can add, edit, or delete users, as well as view their status and statistics. +4. **Key Generator**: This section allows you to generate WireGuard keys locally on your browser. The generated keys are never sent to the server. This is useful if you want to generate keys for a new peer without having to store the private keys in the database. +5. **Profile / Settings**: This section allows you to access your own profile page, settings, and audit logs. + + +### Interface View + +![WireGuard Portal Interface View](../../assets/images/interface_view.png) + +The interface view provides an overview of the WireGuard interfaces and peers configured in WireGuard Portal. + +The most important elements are: + +1. **Interface Selector**: This dropdown allows you to select the WireGuard interface you want to manage. + All further actions will be performed on the selected interface. +2. **Create new Interface**: This button allows you to create a new WireGuard interface. +3. **Interface Overview**: This section provides an overview of the selected WireGuard interface. It shows the interface type, number of peers, and other important information. +4. **List of Peers**: This section provides a list of all peers associated with the selected WireGuard interface. You can view, add, edit, or delete peers from this list. +5. **Add new Peer**: This button allows you to add a new peer to the selected WireGuard interface. +6. **Add multiple Peers**: This button allows you to add multiple peers to the selected WireGuard interface. + This is useful if you want to add a large number of peers at once. \ No newline at end of file diff --git a/docs/documentation/usage/ldap.md b/docs/documentation/usage/ldap.md new file mode 100644 index 0000000..d0ebc77 --- /dev/null +++ b/docs/documentation/usage/ldap.md @@ -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. \ No newline at end of file diff --git a/docs/documentation/usage/security.md b/docs/documentation/usage/security.md new file mode 100644 index 0000000..41f36aa --- /dev/null +++ b/docs/documentation/usage/security.md @@ -0,0 +1,160 @@ +This section describes the security features available to administrators for hardening WireGuard Portal and protecting its data. + +## Authentication + +WireGuard Portal supports multiple authentication methods, including: + +- Local user accounts +- LDAP authentication +- OAuth and OIDC authentication +- Passkey authentication (WebAuthn) + +Users can have two roles which limit their permissions in WireGuard Portal: + +- **User**: Can manage their own account and peers. +- **Admin**: Can manage all users and peers, including the ability to manage WireGuard interfaces. + +### Password Security + +WireGuard Portal supports username and password authentication for both local and LDAP-backed accounts. +Local users are stored in the database, while LDAP users are authenticated against an external LDAP server. + +On initial startup, WireGuard Portal automatically creates a local admin account with the password `wgportal-default`. +> :warning: This password must be changed immediately after the first login. + +The minimum password length for all local users can be configured in the [`auth`](../configuration/overview.md#auth) +section of the configuration file. The default value is **16** characters, see [`min_password_length`](../configuration/overview.md#min_password_length). +The minimum password length is also enforced for the default admin user. + + +### Passkey (WebAuthn) Authentication + +Besides the standard authentication mechanisms, WireGuard Portal supports Passkey authentication. +This feature is enabled by default and can be configured in the [`webauthn`](../configuration/overview.md#webauthn-passkeys) section of the configuration file. + +Users can register multiple Passkeys to their account. These Passkeys can be used to log in to the web UI as long as the user is not locked. +> :warning: Passkey authentication does not disable password authentication. The password can still be used to log in (e.g., as a fallback). + +To register a Passkey, open the settings page *(1)* in the web UI and click on the "Register Passkey" *(2)* button. + +![Passkey UI](../../assets/images/passkey_setup.png) + + +### OAuth and OIDC Authentication + +WireGuard Portal supports OAuth and OIDC authentication. You can use any OAuth or OIDC provider that supports the authorization code flow, +such as Google, GitHub, or Keycloak. + +For OAuth or OIDC to work, you need to configure the [`external_url`](../configuration/overview.md#external_url) property in the [`web`](../configuration/overview.md#web) section of the configuration file. +If you are planning to expose the portal to the internet, make sure that the `external_url` is configured to use HTTPS. + +To add OIDC or OAuth authentication to WireGuard Portal, create a Client-ID and Client-Secret in your OAuth provider and +configure a new authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file. +Make sure that each configured provider has a unique `provider_name` property set. Samples can be seen [here](../configuration/examples.md). + +#### Limiting Login to Specific Domains + +You can limit the login to specific domains by setting the `allowed_domains` property for OAuth or OIDC providers. +This property is a comma-separated list of domains that are allowed to log in. The user's email address is checked against this list. +For example, if you want to allow only users with an email address ending in `outlook.com` to log in, set the property as follows: + +```yaml +auth: + oidc: + - provider_name: "oidc1" + # ... other settings + allowed_domains: + - "outlook.com" +``` + +#### Limit Login to Existing Users + +You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth or OIDC providers. +If registration is enabled, new users will be created in the database when they log in for the first time. + +#### Admin Mapping + +You can map users to admin roles based on their attributes in the OAuth or OIDC provider. To do this, set the `admin_mapping` property for the provider. +Administrative access can either be mapped by a specific attribute or by group membership. + +**Attribute specific mapping** can be achieved by setting the `admin_value_regex` and the `is_admin` property. +The `admin_value_regex` property is a regular expression that is matched against the value of the `is_admin` attribute. +The user is granted admin access if the regex matches the attribute value. + +Example: +```yaml +auth: + oidc: + - provider_name: "oidc1" + # ... other settings + field_map: + is_admin: "wg_admin_prop" + admin_mapping: + admin_value_regex: "^true$" +``` +The example above will grant admin access to users with the `wg_admin_prop` attribute set to `true`. + +**Group membership mapping** can be achieved by setting the `admin_group_regex` and `user_groups` property. +The `admin_group_regex` property is a regular expression that is matched against the group names of the user. +The user is granted admin access if the regex matches any of the group names. + +Example: +```yaml +auth: + oidc: + - provider_name: "oidc1" + # ... other settings + field_map: + user_groups: "groups" + admin_mapping: + admin_group_regex: "^the-admin-group$" +``` +The example above will grant admin access to users who are members of the `the-admin-group` group. + + +### LDAP Authentication + +WireGuard Portal supports LDAP authentication. You can use any LDAP server that supports the LDAP protocol, such as Active Directory or OpenLDAP. +Multiple LDAP servers can be configured in the [`auth`](../configuration/overview.md#auth) section of the configuration file. +WireGuard Portal remembers the authentication provider of the user and therefore avoids conflicts between multiple LDAP providers. + +To configure LDAP authentication, create a new [`ldap`](../configuration/overview.md#ldap) authentication provider in the [`auth`](../configuration/overview.md#auth) section of the configuration file. + +#### Limiting Login to Specific Users + +You can limit the login to specific users by setting the `login_filter` property for LDAP provider. This filter uses the LDAP search filter syntax. +The username can be inserted into the query by placing the `{{login_identifier}}` placeholder in the filter. This placeholder will then be replaced with the username entered by the user during login. + +For example, if you want to allow only users with the `objectClass` attribute set to `organizationalPerson` to log in, set the property as follows: + +```yaml +auth: + ldap: + - provider_name: "ldap1" + # ... other settings + login_filter: "(&(objectClass=organizationalPerson)(uid={{login_identifier}}))" +``` + +The `login_filter` should always be designed to return at most one user. + +#### Limit Login to Existing Users + +You can limit the login to existing users only by setting the `registration_enabled` property to `false` for LDAP providers. +If registration is enabled, new users will be created in the database when they log in for the first time. + +#### Admin Mapping + +You can map users to admin roles based on their group membership in the LDAP server. To do this, set the `admin_group` and `memberof` property for the provider. +The `admin_group` property defines the distinguished name of the group that is allowed to log in as admin. +All groups that are listed in the `memberof` attribute of the user will be checked against this group. If one of the groups matches, the user is granted admin access. + + +## UI and API Access + +WireGuard Portal provides a web UI and a REST API for user interaction. It is important to secure these interfaces to prevent unauthorized access and data breaches. + +### HTTPS +It is recommended to use HTTPS for all communication with the portal to prevent eavesdropping. + +Event though, WireGuard Portal supports HTTPS out of the box, it is recommended to use a reverse proxy like Nginx or Traefik to handle SSL termination and other security features. +A detailed explanation is available in the [Reverse Proxy](../getting-started/reverse-proxy.md) section. \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f12f2f7..afbb37b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@fortawesome/fontawesome-free": "^6.7.2", "@kyvg/vue3-notification": "^3.4.1", "@popperjs/core": "^2.11.8", + "@simplewebauthn/browser": "^13.1.0", "@vojtechlanka/vue-tags-input": "^3.1.1", "bootstrap": "^5.3.5", "bootswatch": "^5.3.5", @@ -863,6 +864,12 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz", + "integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index baf850f..0be893a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@fortawesome/fontawesome-free": "^6.7.2", "@kyvg/vue3-notification": "^3.4.1", "@popperjs/core": "^2.11.8", + "@simplewebauthn/browser": "^13.1.0", "@vojtechlanka/vue-tags-input": "^3.1.1", "bootstrap": "^5.3.5", "bootswatch": "^5.3.5", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cffbb8a..e6e20d2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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; +}) diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json index 32a4251..ff8af74 100644 --- a/frontend/src/lang/translations/de.json +++ b/frontend/src/lang/translations/de.json @@ -29,7 +29,8 @@ "label": "Passwort", "placeholder": "Bitte geben Sie Ihr Passwort ein" }, - "button": "Anmelden" + "button": "Anmelden", + "button-webauthn": "Passkey verwenden" }, "menu": { "home": "Home", @@ -188,6 +189,35 @@ "button-enable-title": "Aktivieren Sie die API, dadurch wird ein neuer Token generiert.", "button-enable-text": "API aktivieren", "api-link": "API Dokumentation" + }, + "webauthn": { + "headline": "Passkey-Einstellungen", + "abstract": "Passkeys sind eine moderne Möglichkeit, Benutzer ohne Passwort zu authentifizieren. Sie werden sicher in Ihrem Browser gespeichert und können verwendet werden, um sich im WireGuard-Portal anzumelden.", + "active-description": "Mindestens ein Passkey ist derzeit für Ihr Benutzerkonto aktiv.", + "inactive-description": "Für Ihr Benutzerkonto sind derzeit keine Passkeys registriert. Drücken Sie die Schaltfläche unten, um einen neuen Passkey zu registrieren.", + "table": { + "name": "Name", + "created": "Erstellt", + "actions": "" + }, + "credentials-list": "Derzeit registrierte Passkeys", + "modal-delete": { + "headline": "Passkey löschen", + "abstract": "Sind Sie sicher, dass Sie diesen Passkey löschen möchten? Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.", + "created": "Erstellt:", + "button-delete": "Löschen", + "button-cancel": "Abbrechen" + }, + "button-rename-title": "Umbenennen", + "button-rename-text": "Passkey umbenennen.", + "button-save-title": "Speichern", + "button-save-text": "Neuen Namen des Passkeys speichern.", + "button-cancel-title": "Abbrechen", + "button-cancel-text": "Umbenennung des Passkeys abbrechen.", + "button-delete-title": "Löschen", + "button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.", + "button-register-title": "Passkey registrieren", + "button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern." } }, "audit": { @@ -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", diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json index 49ef9a8..06e95e0 100644 --- a/frontend/src/lang/translations/en.json +++ b/frontend/src/lang/translations/en.json @@ -29,7 +29,8 @@ "label": "Password", "placeholder": "Please enter your password" }, - "button": "Sign in" + "button": "Sign in", + "button-webauthn": "Use Passkey" }, "menu": { "home": "Home", @@ -188,6 +189,35 @@ "button-enable-title": "Enable API, this will generate a new token.", "button-enable-text": "Enable API", "api-link": "API Documentation" + }, + "webauthn": { + "headline": "Passkey Settings", + "abstract": "Passkeys are a modern way to authenticate users without the need for passwords. They are stored securely in your browser and can be used to log in to the WireGuard Portal.", + "active-description": "At least one passkey is currently active for your user account.", + "inactive-description": "No passkeys are currently registered for your user account. Press the button below to register a new passkey.", + "table": { + "name": "Name", + "created": "Created", + "actions": "" + }, + "credentials-list": "Currently registered Passkeys", + "modal-delete": { + "headline": "Delete Passkey", + "abstract": "Are you sure you want to delete this passkey? You will not be able to log in with this passkey anymore.", + "created": "Created:", + "button-delete": "Delete", + "button-cancel": "Cancel" + }, + "button-rename-title": "Rename", + "button-rename-text": "Rename the passkey.", + "button-save-title": "Save", + "button-save-text": "Save the new name of the passkey.", + "button-cancel-title": "Cancel", + "button-cancel-text": "Cancel the renaming of the passkey.", + "button-delete-title": "Delete", + "button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.", + "button-register-title": "Register Passkey", + "button-register-text": "Register a new Passkey to secure your account." } }, "audit": { @@ -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", diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index b7619b2..50c0d09 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -3,13 +3,17 @@ import { defineStore } from 'pinia' import { notify } from "@kyvg/vue3-notification"; import { apiWrapper } from '@/helpers/fetch-wrapper' import router from '../router' +import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser'; +import {base64_url_encode} from "@/helpers/encoding"; export const authStore = defineStore('auth',{ state: () => ({ // initialize state from local storage to enable user to stay logged in user: JSON.parse(localStorage.getItem('user')), providers: [], - returnUrl: localStorage.getItem('returnUrl') + returnUrl: localStorage.getItem('returnUrl'), + webAuthnCredentials: [], + fetching: false, }), getters: { UserIdentifier: (state) => state.user?.Identifier || 'unknown', @@ -18,6 +22,14 @@ export const authStore = defineStore('auth',{ IsAuthenticated: (state) => state.user != null, IsAdmin: (state) => state.user?.IsAdmin || false, ReturnUrl: (state) => state.returnUrl || '/', + IsWebAuthnEnabled: (state) => { + if (state.webAuthnCredentials) { + return state.webAuthnCredentials.length > 0 + } + return false + }, + WebAuthnCredentials: (state) => state.webAuthnCredentials || [], + isFetching: (state) => state.fetching, }, actions: { SetReturnUrl(link) { @@ -60,6 +72,23 @@ export const authStore = defineStore('auth',{ return Promise.reject(err) }) }, + // LoadWebAuthnCredentials returns promise that might have been rejected if the session was not authenticated. + async LoadWebAuthnCredentials() { + this.fetching = true + return apiWrapper.get(`/auth/webauthn/credentials`) + .then(credentials => { + this.setWebAuthnCredentials(credentials) + }) + .catch(error => { + this.setWebAuthnCredentials([]) + console.log("Failed to load webauthn credentials:", error) + notify({ + title: "Backend Connection Failure", + text: error, + type: 'error', + }) + }) + }, // Login returns promise that might have been rejected if the login attempt was not successful. async Login(username, password) { return apiWrapper.post(`/auth/login`, { username, password }) @@ -93,6 +122,157 @@ export const authStore = defineStore('auth',{ await router.push('/login') }, + async RegisterWebAuthn() { + // check if the browser supports WebAuthn + if (!browserSupportsWebAuthn()) { + console.error("WebAuthn is not supported by this browser."); + notify({ + title: "WebAuthn not supported", + text: "This browser does not support WebAuthn.", + type: 'error' + }); + return Promise.reject(new Error("WebAuthn not supported")); + } + + this.fetching = true + console.log("Starting WebAuthn registration...") + await apiWrapper.post(`/auth/webauthn/register/start`, {}) + .then(optionsJSON => { + notify({ + title: "Passkey registration", + text: "Starting passkey registration, follow the instructions in the browser." + }); + console.log("Started WebAuthn registration with options: ", optionsJSON) + + return startRegistration({ optionsJSON: optionsJSON.publicKey }).then(attResp => { + console.log("Finishing WebAuthn registration...") + return apiWrapper.post(`/auth/webauthn/register/finish`, attResp) + .then(credentials => { + console.log("Passkey registration finished successfully: ", credentials) + this.setWebAuthnCredentials(credentials) + notify({ + title: "Passkey registration", + text: "A new passkey has been registered successfully!", + type: 'success' + }); + }) + .catch(err => { + this.fetching = false + console.error("Failed to register passkey:", err); + notify({ + title: "Passkey registration failed", + text: err, + type: 'error' + }); + }) + }).catch(err => { + this.fetching = false + console.error("Failed to start WebAuthn registration:", err); + notify({ + title: "Failed to start Passkey registration", + text: err, + type: 'error' + }); + }) + }) + .catch(err => { + this.fetching = false + console.error("Failed to start WebAuthn registration:", err); + notify({ + title: "Failed to start WebAuthn registration", + text: err, + type: 'error' + }); + }) + }, + async DeleteWebAuthnCredential(credentialId) { + this.fetching = true + return apiWrapper.delete(`/auth/webauthn/credential/${base64_url_encode(credentialId)}`) + .then(credentials => { + this.setWebAuthnCredentials(credentials) + notify({ + title: "Success", + text: "Passkey deleted successfully!", + type: 'success', + }) + }) + .catch(err => { + this.fetching = false + console.error("Failed to delete webauthn credential:", err); + notify({ + title: "Backend Connection Failure", + text: err, + type: 'error', + }) + }) + }, + async RenameWebAuthnCredential(credential) { + this.fetching = true + return apiWrapper.put(`/auth/webauthn/credential/${base64_url_encode(credential.ID)}`, { + Name: credential.Name, + }) + .then(credentials => { + this.setWebAuthnCredentials(credentials) + notify({ + title: "Success", + text: "Passkey renamed successfully!", + type: 'success', + }) + }) + .catch(err => { + this.fetching = false + console.error("Failed to rename webauthn credential", credential.ID, ":", err); + notify({ + title: "Backend Connection Failure", + text: err, + type: 'error', + }) + }) + }, + async LoginWebAuthn() { + // check if the browser supports WebAuthn + if (!browserSupportsWebAuthn()) { + console.error("WebAuthn is not supported by this browser."); + notify({ + title: "WebAuthn not supported", + text: "This browser does not support WebAuthn.", + type: 'error' + }); + return Promise.reject(new Error("WebAuthn not supported")); + } + + this.fetching = true + console.log("Starting WebAuthn login...") + await apiWrapper.post(`/auth/webauthn/login/start`, {}) + .then(optionsJSON => { + console.log("Started WebAuthn login with options: ", optionsJSON) + + return startAuthentication({ optionsJSON: optionsJSON.publicKey }).then(asseResp => { + console.log("Finishing WebAuthn login ...") + return apiWrapper.post(`/auth/webauthn/login/finish`, asseResp) + .then(user => { + console.log("Passkey login finished successfully for user:", user.Identifier) + this.ResetReturnUrl() + this.setUserInfo(user) + return user.Identifier + }) + .catch(err => { + console.error("Failed to login with passkey:", err) + this.setUserInfo(null) + return Promise.reject(new Error("login failed")) + }) + }).catch(err => { + console.error("Failed to finish passkey login:", err) + this.setUserInfo(null) + return Promise.reject(new Error("login failed")) + }) + }) + .catch(err => { + console.error("Failed to start passkey login:", err) + this.setUserInfo(null) + return Promise.reject(new Error("login failed")) + }) + }, // -- internal setters setUserInfo(userInfo) { // store user details and jwt in local storage to keep user logged in between page refreshes @@ -120,5 +300,9 @@ export const authStore = defineStore('auth',{ localStorage.removeItem('user') } }, + setWebAuthnCredentials(credentials) { + this.fetching = false + this.webAuthnCredentials = credentials + } } }); diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index a771004..0291ca7 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -65,7 +65,7 @@ const auth = authStore()

{{ $t('home.about-portal.headline') }}

{{ $t('home.about-portal.content') }}

- {{ $t('home.about-portal.button') }}
diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 1523c27..d5037e2 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,6 +1,6 @@ diff --git a/go.mod b/go.mod index 6e91eb1..b83e54c 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-pkgz/routegroup v1.4.1 github.com/go-playground/validator/v10 v10.26.0 + github.com/go-webauthn/webauthn v0.12.3 github.com/google/uuid v1.6.0 github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus/client_golang v1.22.0 @@ -39,6 +40,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fxamacker/cbor/v2 v2.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect @@ -51,9 +53,12 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-test/deep v1.1.1 // indirect + github.com/go-webauthn/x v0.1.20 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-tpm v0.9.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.4 // indirect @@ -69,6 +74,7 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/microsoft/go-mssqldb v1.8.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -78,6 +84,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect github.com/vishvananda/netns v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/net v0.39.0 // indirect diff --git a/go.sum b/go.sum index f13a87c..c427c3f 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= +github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= @@ -79,10 +81,14 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE= +github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY= +github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw= +github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= @@ -90,6 +96,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc= +github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -154,6 +162,8 @@ github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3ao github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -202,6 +212,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= diff --git a/internal/adapters/database.go b/internal/adapters/database.go index 0ae20ad..14726ae 100644 --- a/internal/adapters/database.go +++ b/internal/adapters/database.go @@ -220,6 +220,8 @@ func (r *SqlRepo) preCheck() error { func (r *SqlRepo) migrate() error { slog.Debug("running migration: sys-stat", "result", r.db.AutoMigrate(&SysStat{})) slog.Debug("running migration: user", "result", r.db.AutoMigrate(&domain.User{})) + slog.Debug("running migration: user webauthn credentials", "result", + r.db.AutoMigrate(&domain.UserWebauthnCredential{})) slog.Debug("running migration: interface", "result", r.db.AutoMigrate(&domain.Interface{})) slog.Debug("running migration: peer", "result", r.db.AutoMigrate(&domain.Peer{})) slog.Debug("running migration: peer status", "result", r.db.AutoMigrate(&domain.PeerStatus{})) @@ -746,7 +748,7 @@ func (r *SqlRepo) GetUsedIpsPerSubnet(ctx context.Context, subnets []domain.Cidr func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) { var user domain.User - err := r.db.WithContext(ctx).First(&user, id).Error + err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").First(&user, id).Error if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { return nil, domain.ErrNotFound @@ -764,7 +766,7 @@ func (r *SqlRepo) GetUser(ctx context.Context, id domain.UserIdentifier) (*domai func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { var users []domain.User - err := r.db.WithContext(ctx).Where("email = ?", email).Find(&users).Error + err := r.db.WithContext(ctx).Where("email = ?", email).Preload("WebAuthnCredentialList").Find(&users).Error if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { return nil, domain.ErrNotFound } @@ -785,11 +787,26 @@ func (r *SqlRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Use return &user, nil } +// GetUserByWebAuthnCredential returns the user with the given webauthn credential id. +func (r *SqlRepo) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) { + var credential domain.UserWebauthnCredential + + err := r.db.WithContext(ctx).Where("credential_identifier = ?", credentialIdBase64).First(&credential).Error + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, err + } + + return r.GetUser(ctx, domain.UserIdentifier(credential.UserIdentifier)) +} + // GetAllUsers returns all users. func (r *SqlRepo) GetAllUsers(ctx context.Context) ([]domain.User, error) { var users []domain.User - err := r.db.WithContext(ctx).Find(&users).Error + err := r.db.WithContext(ctx).Preload("WebAuthnCredentialList").Find(&users).Error if err != nil { return nil, err } @@ -808,6 +825,7 @@ func (r *SqlRepo) FindUsers(ctx context.Context, search string) ([]domain.User, Or("firstname LIKE ?", searchValue). Or("lastname LIKE ?", searchValue). Or("email LIKE ?", searchValue). + Preload("WebAuthnCredentialList"). Find(&users).Error if err != nil { return nil, err @@ -853,7 +871,7 @@ func (r *SqlRepo) SaveUser( // DeleteUser deletes the user with the given id. func (r *SqlRepo) DeleteUser(ctx context.Context, id domain.UserIdentifier) error { - err := r.db.WithContext(ctx).Delete(&domain.User{}, id).Error + err := r.db.WithContext(ctx).Unscoped().Select(clause.Associations).Delete(&domain.User{Identifier: id}).Error if err != nil { return err } @@ -897,6 +915,11 @@ func (r *SqlRepo) upsertUser(ui *domain.ContextUserInfo, tx *gorm.DB, user *doma return err } + err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Unscoped().Model(user).Association("WebAuthnCredentialList").Unscoped().Replace(user.WebAuthnCredentialList) + if err != nil { + return fmt.Errorf("failed to update users webauthn credentials: %w", err) + } + return nil } diff --git a/internal/app/api/core/assets/doc/v0_swagger.json b/internal/app/api/core/assets/doc/v0_swagger.json index 3ca06a1..0658046 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.json +++ b/internal/app/api/core/assets/doc/v0_swagger.json @@ -129,6 +129,152 @@ } } }, + "/auth/webauthn/credential/{id}": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Update a WebAuthn credential.", + "operationId": "auth_handleWebAuthnCredentialsPut", + "parameters": [ + { + "type": "string", + "description": "Base64 encoded Credential ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Credential name", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.WebAuthnCredentialRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.WebAuthnCredentialResponse" + } + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Delete a WebAuthn credential.", + "operationId": "auth_handleWebAuthnCredentialsDelete", + "parameters": [ + { + "type": "string", + "description": "Base64 encoded Credential ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.WebAuthnCredentialResponse" + } + } + } + } + } + }, + "/auth/webauthn/credentials": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Get all available external login providers.", + "operationId": "auth_handleWebAuthnCredentialsGet", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.WebAuthnCredentialResponse" + } + } + } + } + } + }, + "/auth/webauthn/login/finish": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Finish the WebAuthn login process.", + "operationId": "auth_handleWebAuthnLoginFinish", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + } + } + } + }, + "/auth/webauthn/register/finish": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Finish the WebAuthn registration process.", + "operationId": "auth_handleWebAuthnRegisterFinish", + "parameters": [ + { + "type": "string", + "default": "\"\"", + "description": "Credential name", + "name": "credential_name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.WebAuthnCredentialResponse" + } + } + } + } + } + }, "/auth/{provider}/callback": { "get": { "produces": [ @@ -2093,6 +2239,9 @@ }, "SelfProvisioning": { "type": "boolean" + }, + "WebAuthnEnabled": { + "type": "boolean" } } }, @@ -2161,6 +2310,28 @@ "type": "string" } } + }, + "model.WebAuthnCredentialRequest": { + "type": "object", + "properties": { + "Name": { + "type": "string" + } + } + }, + "model.WebAuthnCredentialResponse": { + "type": "object", + "properties": { + "CreatedAt": { + "type": "string" + }, + "ID": { + "type": "string" + }, + "Name": { + "type": "string" + } + } } } } \ No newline at end of file diff --git a/internal/app/api/core/assets/doc/v0_swagger.yaml b/internal/app/api/core/assets/doc/v0_swagger.yaml index ab42b60..e8778b2 100644 --- a/internal/app/api/core/assets/doc/v0_swagger.yaml +++ b/internal/app/api/core/assets/doc/v0_swagger.yaml @@ -387,6 +387,8 @@ definitions: type: boolean SelfProvisioning: type: boolean + WebAuthnEnabled: + type: boolean type: object model.User: properties: @@ -433,6 +435,20 @@ definitions: Source: type: string type: object + model.WebAuthnCredentialRequest: + properties: + Name: + type: string + type: object + model.WebAuthnCredentialResponse: + properties: + CreatedAt: + type: string + ID: + type: string + Name: + type: string + type: object info: contact: name: WireGuard Portal Developers @@ -548,6 +564,102 @@ paths: summary: Get information about the currently logged-in user. tags: - Authentication + /auth/webauthn/credential/{id}: + delete: + operationId: auth_handleWebAuthnCredentialsDelete + parameters: + - description: Base64 encoded Credential ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.WebAuthnCredentialResponse' + type: array + summary: Delete a WebAuthn credential. + tags: + - Authentication + put: + operationId: auth_handleWebAuthnCredentialsPut + parameters: + - description: Base64 encoded Credential ID + in: path + name: id + required: true + type: string + - description: Credential name + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.WebAuthnCredentialRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.WebAuthnCredentialResponse' + type: array + summary: Update a WebAuthn credential. + tags: + - Authentication + /auth/webauthn/credentials: + get: + operationId: auth_handleWebAuthnCredentialsGet + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.WebAuthnCredentialResponse' + type: array + summary: Get all available external login providers. + tags: + - Authentication + /auth/webauthn/login/finish: + post: + operationId: auth_handleWebAuthnLoginFinish + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.User' + summary: Finish the WebAuthn login process. + tags: + - Authentication + /auth/webauthn/register/finish: + post: + operationId: auth_handleWebAuthnRegisterFinish + parameters: + - default: '""' + description: Credential name + in: query + name: credential_name + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.WebAuthnCredentialResponse' + type: array + summary: Finish the WebAuthn registration process. + tags: + - Authentication /config/frontend.js: get: operationId: config_handleConfigJsGet diff --git a/internal/app/api/v0/handlers/base.go b/internal/app/api/v0/handlers/base.go index 38ac024..bee1d05 100644 --- a/internal/app/api/v0/handlers/base.go +++ b/internal/app/api/v0/handlers/base.go @@ -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 { diff --git a/internal/app/api/v0/handlers/endpoint_authentication.go b/internal/app/api/v0/handlers/endpoint_authentication.go index 56a889b..de412e3 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication.go +++ b/internal/app/api/v0/handlers/endpoint_authentication.go @@ -28,12 +28,54 @@ type AuthenticationService interface { OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) } +type WebAuthnService interface { + Enabled() bool + StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) ( + responseOptions []byte, + sessionData []byte, + err error, + ) + FinishWebAuthnRegistration( + ctx context.Context, + userId domain.UserIdentifier, + name string, + sessionDataAsJSON []byte, + r *http.Request, + ) ([]domain.UserWebauthnCredential, error) + GetCredentials( + ctx context.Context, + userId domain.UserIdentifier, + ) ([]domain.UserWebauthnCredential, error) + RemoveCredential( + ctx context.Context, + userId domain.UserIdentifier, + credentialIdBase64 string, + ) ([]domain.UserWebauthnCredential, error) + UpdateCredential( + ctx context.Context, + userId domain.UserIdentifier, + credentialIdBase64 string, + name string, + ) ([]domain.UserWebauthnCredential, error) + StartWebAuthnLogin(_ context.Context) ( + optionsAsJSON []byte, + sessionDataAsJSON []byte, + err error, + ) + FinishWebAuthnLogin( + ctx context.Context, + sessionDataAsJSON []byte, + r *http.Request, + ) (*domain.User, error) +} + type AuthEndpoint struct { cfg *config.Config authService AuthenticationService authenticator Authenticator session Session validate Validator + webAuthn WebAuthnService } func NewAuthEndpoint( @@ -42,6 +84,7 @@ func NewAuthEndpoint( session Session, validator Validator, authService AuthenticationService, + webAuthn WebAuthnService, ) AuthEndpoint { return AuthEndpoint{ cfg: cfg, @@ -49,6 +92,7 @@ func NewAuthEndpoint( authenticator: authenticator, session: session, validate: validator, + webAuthn: webAuthn, } } @@ -65,6 +109,19 @@ func (e AuthEndpoint) RegisterRoutes(g *routegroup.Bundle) { apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet()) apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet()) + apiGroup.HandleFunc("POST /webauthn/login/start", e.handleWebAuthnLoginStart()) + apiGroup.HandleFunc("POST /webauthn/login/finish", e.handleWebAuthnLoginFinish()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /webauthn/credentials", + e.handleWebAuthnCredentialsGet()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/start", + e.handleWebAuthnRegisterStart()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/finish", + e.handleWebAuthnRegisterFinish()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("DELETE /webauthn/credential/{id}", + e.handleWebAuthnCredentialsDelete()) + apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("PUT /webauthn/credential/{id}", + e.handleWebAuthnCredentialsPut()) + apiGroup.HandleFunc("POST /login", e.handleLoginPost()) apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost()) } @@ -389,3 +446,237 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool { return true } + +// handleWebAuthnCredentialsGet returns a gorm Handler function. +// +// @ID auth_handleWebAuthnCredentialsGet +// @Tags Authentication +// @Summary Get all available external login providers. +// @Produce json +// @Success 200 {object} []model.WebAuthnCredentialResponse +// @Router /auth/webauthn/credentials [get] +func (e AuthEndpoint) handleWebAuthnCredentialsGet() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusOK, []model.WebAuthnCredentialResponse{}) + return + } + + currentSession := e.session.GetData(r.Context()) + + userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) + + credentials, err := e.webAuthn.GetCredentials(r.Context(), userIdentifier) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials)) + } +} + +// handleWebAuthnCredentialsDelete returns a gorm Handler function. +// +// @ID auth_handleWebAuthnCredentialsDelete +// @Tags Authentication +// @Summary Delete a WebAuthn credential. +// @Param id path string true "Base64 encoded Credential ID" +// @Produce json +// @Success 200 {object} []model.WebAuthnCredentialResponse +// @Router /auth/webauthn/credential/{id} [delete] +func (e AuthEndpoint) handleWebAuthnCredentialsDelete() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) + + credentialId := Base64UrlDecode(request.Path(r, "id")) + + credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials)) + } +} + +// handleWebAuthnCredentialsPut returns a gorm Handler function. +// +// @ID auth_handleWebAuthnCredentialsPut +// @Tags Authentication +// @Summary Update a WebAuthn credential. +// @Param id path string true "Base64 encoded Credential ID" +// @Param request body model.WebAuthnCredentialRequest true "Credential name" +// @Produce json +// @Success 200 {object} []model.WebAuthnCredentialResponse +// @Router /auth/webauthn/credential/{id} [put] +func (e AuthEndpoint) handleWebAuthnCredentialsPut() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) + + credentialId := Base64UrlDecode(request.Path(r, "id")) + var req model.WebAuthnCredentialRequest + if err := request.BodyJson(r, &req); err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + credentials, err := e.webAuthn.UpdateCredential(r.Context(), userIdentifier, credentialId, req.Name) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials)) + } +} + +func (e AuthEndpoint) handleWebAuthnRegisterStart() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) + + options, sessionData, err := e.webAuthn.StartWebAuthnRegistration(r.Context(), userIdentifier) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + currentSession.WebAuthnData = string(sessionData) + e.session.SetData(r.Context(), currentSession) + + respond.Data(w, http.StatusOK, "application/json", options) + } +} + +// handleWebAuthnRegisterFinish returns a gorm Handler function. +// +// @ID auth_handleWebAuthnRegisterFinish +// @Tags Authentication +// @Summary Finish the WebAuthn registration process. +// @Param credential_name query string false "Credential name" default("") +// @Produce json +// @Success 200 {object} []model.WebAuthnCredentialResponse +// @Router /auth/webauthn/register/finish [post] +func (e AuthEndpoint) handleWebAuthnRegisterFinish() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + name := request.QueryDefault(r, "credential_name", "") + + currentSession := e.session.GetData(r.Context()) + + webAuthnSessionData := []byte(currentSession.WebAuthnData) + currentSession.WebAuthnData = "" // clear the session data + e.session.SetData(r.Context(), currentSession) + + credentials, err := e.webAuthn.FinishWebAuthnRegistration( + r.Context(), + domain.UserIdentifier(currentSession.UserIdentifier), + name, + webAuthnSessionData, + r) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials)) + } +} + +func (e AuthEndpoint) handleWebAuthnLoginStart() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + options, sessionData, err := e.webAuthn.StartWebAuthnLogin(r.Context()) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + currentSession.WebAuthnData = string(sessionData) + e.session.SetData(r.Context(), currentSession) + + respond.Data(w, http.StatusOK, "application/json", options) + } +} + +// handleWebAuthnLoginFinish returns a gorm Handler function. +// +// @ID auth_handleWebAuthnLoginFinish +// @Tags Authentication +// @Summary Finish the WebAuthn login process. +// @Produce json +// @Success 200 {object} model.User +// @Router /auth/webauthn/login/finish [post] +func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !e.webAuthn.Enabled() { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"}) + return + } + + currentSession := e.session.GetData(r.Context()) + + webAuthnSessionData := []byte(currentSession.WebAuthnData) + currentSession.WebAuthnData = "" // clear the session data + e.session.SetData(r.Context(), currentSession) + + user, err := e.webAuthn.FinishWebAuthnLogin( + r.Context(), + webAuthnSessionData, + r) + if err != nil { + respond.JSON(w, http.StatusBadRequest, + model.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + e.setAuthenticatedUser(r, user) + + respond.JSON(w, http.StatusOK, model.NewUser(user, false)) + } +} diff --git a/internal/app/api/v0/handlers/endpoint_config.go b/internal/app/api/v0/handlers/endpoint_config.go index c81c2c9..a99effe 100644 --- a/internal/app/api/v0/handlers/endpoint_config.go +++ b/internal/app/api/v0/handlers/endpoint_config.go @@ -15,6 +15,7 @@ import ( "github.com/h44z/wg-portal/internal/app/api/core/respond" "github.com/h44z/wg-portal/internal/app/api/v0/model" "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" ) //go:embed frontend_config.js.gotpl @@ -46,7 +47,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) { apiGroup := g.Mount("/config") apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet()) - apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /settings", e.handleSettingsGet()) + apiGroup.With(e.authenticator.InfoOnly()).HandleFunc("GET /settings", e.handleSettingsGet()) } // handleConfigJsGet returns a gorm Handler function. @@ -93,11 +94,22 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc { // @Router /config/settings [get] func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - respond.JSON(w, http.StatusOK, model.Settings{ - MailLinkOnly: e.cfg.Mail.LinkOnly, - PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "", - SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed, - ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, - }) + sessionUser := domain.GetUserInfo(r.Context()) + + // For anonymous users, we return the settings object with minimal information + if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" { + respond.JSON(w, http.StatusOK, model.Settings{ + WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, + }) + } else { + respond.JSON(w, http.StatusOK, model.Settings{ + MailLinkOnly: e.cfg.Mail.LinkOnly, + PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "", + SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed, + ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, + WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, + MinPasswordLength: e.cfg.Auth.MinPasswordLength, + }) + } } } diff --git a/internal/app/api/v0/handlers/web_authentication.go b/internal/app/api/v0/handlers/web_authentication.go index 1b6a570..f214739 100644 --- a/internal/app/api/v0/handlers/web_authentication.go +++ b/internal/app/api/v0/handlers/web_authentication.go @@ -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 { diff --git a/internal/app/api/v0/handlers/web_session.go b/internal/app/api/v0/handlers/web_session.go index dd4ca6c..1e12eaa 100644 --- a/internal/app/api/v0/handlers/web_session.go +++ b/internal/app/api/v0/handlers/web_session.go @@ -31,6 +31,8 @@ type SessionData struct { OauthProvider string OauthReturnTo string + WebAuthnData string + CsrfToken string } diff --git a/internal/app/api/v0/model/models.go b/internal/app/api/v0/model/models.go index e2298bc..847b139 100644 --- a/internal/app/api/v0/model/models.go +++ b/internal/app/api/v0/model/models.go @@ -10,4 +10,6 @@ type Settings struct { PersistentConfigSupported bool `json:"PersistentConfigSupported"` SelfProvisioning bool `json:"SelfProvisioning"` ApiAdminOnly bool `json:"ApiAdminOnly"` + WebAuthnEnabled bool `json:"WebAuthnEnabled"` + MinPasswordLength int `json:"MinPasswordLength"` } diff --git a/internal/app/api/v0/model/models_authentication.go b/internal/app/api/v0/model/models_authentication.go index 1966405..b7283b1 100644 --- a/internal/app/api/v0/model/models_authentication.go +++ b/internal/app/api/v0/model/models_authentication.go @@ -1,6 +1,11 @@ package model -import "github.com/h44z/wg-portal/internal/domain" +import ( + "slices" + "strings" + + "github.com/h44z/wg-portal/internal/domain" +) type LoginProviderInfo struct { Identifier string `json:"Identifier" example:"google"` @@ -39,3 +44,32 @@ type OauthInitiationResponse struct { RedirectUrl string State string } + +type WebAuthnCredentialRequest struct { + Name string `json:"Name"` +} +type WebAuthnCredentialResponse struct { + ID string `json:"ID"` + Name string `json:"Name"` + CreatedAt string `json:"CreatedAt"` +} + +func NewWebAuthnCredentialResponse(src domain.UserWebauthnCredential) WebAuthnCredentialResponse { + return WebAuthnCredentialResponse{ + ID: src.CredentialIdentifier, + Name: src.DisplayName, + CreatedAt: src.CreatedAt.Format("2006-01-02 15:04:05"), + } +} + +func NewWebAuthnCredentialResponses(src []domain.UserWebauthnCredential) []WebAuthnCredentialResponse { + credentials := make([]WebAuthnCredentialResponse, len(src)) + for i := range src { + credentials[i] = NewWebAuthnCredentialResponse(src[i]) + } + // Sort by CreatedAt, newest first + slices.SortFunc(credentials, func(i, j WebAuthnCredentialResponse) int { + return strings.Compare(i.CreatedAt, j.CreatedAt) + }) + return credentials +} diff --git a/internal/app/auth/webauthn.go b/internal/app/auth/webauthn.go new file mode 100644 index 0000000..f9c9874 --- /dev/null +++ b/internal/app/auth/webauthn.go @@ -0,0 +1,301 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/h44z/wg-portal/internal/app" + "github.com/h44z/wg-portal/internal/app/audit" + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" +) + +type WebAuthnUserManager interface { + // GetUser returns a user by its identifier. + GetUser(context.Context, domain.UserIdentifier) (*domain.User, error) + // GetUserByWebAuthnCredential returns a user by its WebAuthn ID. + GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) + // UpdateUser updates an existing user in the database. + UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error) +} + +type WebAuthnAuthenticator struct { + webAuthn *webauthn.WebAuthn + users WebAuthnUserManager + bus EventBus +} + +func NewWebAuthnAuthenticator(cfg *config.Config, bus EventBus, users WebAuthnUserManager) ( + *WebAuthnAuthenticator, + error, +) { + if !cfg.Auth.WebAuthn.Enabled { + return nil, nil + } + + extUrl, err := url.Parse(cfg.Web.ExternalUrl) + if err != nil { + return nil, errors.New("failed to parse external URL - required for WebAuthn RP ID") + } + + rpId := extUrl.Hostname() + if rpId == "" { + return nil, errors.New("failed to determine Webauthn RPID") + } + + // Initialize the WebAuthn authenticator with the provided configuration + awCfg := &webauthn.Config{ + RPID: rpId, + RPDisplayName: cfg.Web.SiteTitle, + RPOrigins: []string{cfg.Web.ExternalUrl}, + } + + webAuthn, err := webauthn.New(awCfg) + if err != nil { + return nil, fmt.Errorf("failed to create Webauthn instance: %w", err) + } + + return &WebAuthnAuthenticator{ + webAuthn: webAuthn, + users: users, + bus: bus, + }, nil +} + +func (a *WebAuthnAuthenticator) Enabled() bool { + return a != nil && a.webAuthn != nil +} + +func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) ( + optionsAsJSON []byte, + sessionDataAsJSON []byte, + err error, +) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, nil, fmt.Errorf("failed to get user: %w", err) + } + if user.IsLocked() || user.IsDisabled() { + return nil, nil, errors.New("user is locked") // adding passkey to locked user is not allowed + } + + if user.WebAuthnId == "" { + user.GenerateWebAuthnId() + user, err = a.users.UpdateUser(ctx, user) + if err != nil { + return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err) + } + } + + options, sessionData, err := a.webAuthn.BeginRegistration(user, + webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err) + } + + optionsAsJSON, err = json.Marshal(options) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err) + } + sessionDataAsJSON, err = json.Marshal(sessionData) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err) + } + + return optionsAsJSON, sessionDataAsJSON, nil +} + +func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration( + ctx context.Context, + userId domain.UserIdentifier, + name string, + sessionDataAsJSON []byte, + r *http.Request, +) ([]domain.UserWebauthnCredential, error) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + if user.IsLocked() || user.IsDisabled() { + return nil, errors.New("user is locked") // adding passkey to locked user is not allowed + } + + var webAuthnData webauthn.SessionData + err = json.Unmarshal(sessionDataAsJSON, &webAuthnData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err) + } + + credential, err := a.webAuthn.FinishRegistration(user, webAuthnData, r) + if err != nil { + return nil, err + } + + if name == "" { + name = fmt.Sprintf("Passkey %d", len(user.WebAuthnCredentialList)+1) // fallback name + } + + // Add the credential to the user + err = user.AddCredential(userId, name, *credential) + if err != nil { + return nil, err + } + + user, err = a.users.UpdateUser(ctx, user) + if err != nil { + return nil, err + } + + return user.WebAuthnCredentialList, nil +} + +func (a *WebAuthnAuthenticator) GetCredentials( + ctx context.Context, + userId domain.UserIdentifier, +) ([]domain.UserWebauthnCredential, error) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + return user.WebAuthnCredentialList, nil +} + +func (a *WebAuthnAuthenticator) RemoveCredential( + ctx context.Context, + userId domain.UserIdentifier, + credentialIdBase64 string, +) ([]domain.UserWebauthnCredential, error) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + user.RemoveCredential(credentialIdBase64) + user, err = a.users.UpdateUser(ctx, user) + if err != nil { + return nil, err + } + + return user.WebAuthnCredentialList, nil +} + +func (a *WebAuthnAuthenticator) UpdateCredential( + ctx context.Context, + userId domain.UserIdentifier, + credentialIdBase64 string, + name string, +) ([]domain.UserWebauthnCredential, error) { + user, err := a.users.GetUser(ctx, userId) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + err = user.UpdateCredential(credentialIdBase64, name) + if err != nil { + return nil, err + } + + user, err = a.users.UpdateUser(ctx, user) + if err != nil { + return nil, err + } + + return user.WebAuthnCredentialList, nil +} + +func (a *WebAuthnAuthenticator) StartWebAuthnLogin(_ context.Context) ( + optionsAsJSON []byte, + sessionDataAsJSON []byte, + err error, +) { + options, sessionData, err := a.webAuthn.BeginDiscoverableLogin() + if err != nil { + return nil, nil, fmt.Errorf("failed to begin WebAuthn login: %w", err) + } + + optionsAsJSON, err = json.Marshal(options) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err) + } + sessionDataAsJSON, err = json.Marshal(sessionData) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err) + } + + return optionsAsJSON, sessionDataAsJSON, nil +} + +func (a *WebAuthnAuthenticator) FinishWebAuthnLogin( + ctx context.Context, + sessionDataAsJSON []byte, + r *http.Request, +) (*domain.User, error) { + + var webAuthnData webauthn.SessionData + err := json.Unmarshal(sessionDataAsJSON, &webAuthnData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err) + } + + // switch to admin context for user lookup + ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo()) + + credential, err := a.webAuthn.FinishDiscoverableLogin(a.findUserForWebAuthnSecretFn(ctx), webAuthnData, r) + if err != nil { + return nil, err + } + + // Find the user by the WebAuthn ID + user, err := a.users.GetUserByWebAuthnCredential(ctx, + base64.StdEncoding.EncodeToString(credential.ID)) + if err != nil { + return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err) + } + + if user.IsLocked() || user.IsDisabled() { + a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{ + Ctx: ctx, + Source: "passkey", + Event: audit.AuthEvent{ + Username: string(user.Identifier), Error: "User is locked", + }, + }) + return nil, errors.New("user is locked") // login with passkey is not allowed + } + + a.bus.Publish(app.TopicAuthLogin, user.Identifier) + a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{ + Ctx: ctx, + Source: "passkey", + Event: audit.AuthEvent{ + Username: string(user.Identifier), + }, + }) + + return user, nil +} + +func (a *WebAuthnAuthenticator) findUserForWebAuthnSecretFn(ctx context.Context) func(rawID, userHandle []byte) ( + user webauthn.User, + err error, +) { + return func(rawID, userHandle []byte) (webauthn.User, error) { + // Find the user by the WebAuthn ID + user, err := a.users.GetUserByWebAuthnCredential(ctx, base64.StdEncoding.EncodeToString(rawID)) + if err != nil { + return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err) + } + + return user, nil + } +} diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go index 9511e32..11e8ac8 100644 --- a/internal/app/users/user_manager.go +++ b/internal/app/users/user_manager.go @@ -25,6 +25,8 @@ type UserDatabaseRepo interface { GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) // GetUserByEmail returns the user with the given email address. GetUserByEmail(ctx context.Context, email string) (*domain.User, error) + // GetUserByWebAuthnCredential returns the user for the given WebAuthn credential ID. + GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) // GetAllUsers returns all users. GetAllUsers(ctx context.Context) ([]domain.User, error) // FindUsers returns all users matching the search string. @@ -129,6 +131,25 @@ func (m Manager) GetUserByEmail(ctx context.Context, email string) (*domain.User return user, nil } +// GetUserByWebAuthnCredential returns the user for the given WebAuthn credential. +func (m Manager) GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error) { + + user, err := m.users.GetUserByWebAuthnCredential(ctx, credentialIdBase64) + if err != nil { + return nil, fmt.Errorf("unable to load user for webauthn credential %s: %w", credentialIdBase64, err) + } + + if err := domain.ValidateUserAccessRights(ctx, user.Identifier); err != nil { + return nil, err + } + + peers, _ := m.peers.GetUserPeers(ctx, user.Identifier) // ignore error, list will be empty in error case + + user.LinkedPeerCount = len(peers) + + return user, nil +} + // GetAllUsers returns all users. func (m Manager) GetAllUsers(ctx context.Context) ([]domain.User, error) { if err := domain.ValidateAdminAccessRights(ctx); err != nil { @@ -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 diff --git a/internal/config/auth.go b/internal/config/auth.go index 3132fb7..004fc5b 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -16,6 +16,11 @@ 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"` } // BaseFields contains the basic fields that are used to map user information from the authentication providers. @@ -245,3 +250,9 @@ type OAuthProvider struct { // If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level. LogUserInfo bool `yaml:"log_user_info"` } + +// WebauthnConfig contains the configuration for the WebAuthn authenticator. +type WebauthnConfig struct { + // Enabled specifies whether WebAuthn is enabled. + Enabled bool `yaml:"enabled"` +} diff --git a/internal/config/config.go b/internal/config/config.go index 44ef8e4..f8ade2f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -101,7 +101,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 @@ -164,6 +164,9 @@ func defaultConfig() *Config { cfg.Webhook.Authentication = "" cfg.Webhook.Timeout = 10 * time.Second + cfg.Auth.WebAuthn.Enabled = true + cfg.Auth.MinPasswordLength = 16 + return cfg } diff --git a/internal/domain/user.go b/internal/domain/user.go index c85e78e..84b5345 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -2,9 +2,16 @@ package domain import ( "crypto/subtle" + "encoding/base64" + "encoding/json" "errors" + "fmt" + "slices" + "strings" "time" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -43,6 +50,10 @@ type User struct { Locked *time.Time `gorm:"index;column:locked"` // if this field is set, the user is locked and can no longer login (WireGuard peers still can connect) LockedReason string // the reason why the user has been locked + // Passwordless authentication + WebAuthnId string `gorm:"column:webauthn_id"` // the webauthn id of the user, used for webauthn authentication + WebAuthnCredentialList []UserWebauthnCredential `gorm:"foreignKey:user_identifier"` // the webauthn credentials of the user, used for webauthn authentication + // API token for REST API access ApiToken string `form:"api_token" binding:"omitempty"` ApiTokenCreated *time.Time @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 9bafbe5..e6b988b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,6 +78,10 @@ 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 + - 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