Compare commits

..

9 Commits

Author SHA1 Message Date
dependabot[bot]
da76327569 chore(deps): bump golang.org/x/oauth2 from 0.31.0 to 0.32.0 (#544)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.31.0 to 0.32.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.31.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 21:14:22 +02:00
dependabot[bot]
c154cb3977 chore(deps): bump golang.org/x/sys from 0.36.0 to 0.37.0 (#545)
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.36.0 to 0.37.0.
- [Commits](https://github.com/golang/sys/compare/v0.36.0...v0.37.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 21:12:15 +02:00
dependabot[bot]
7bca35728d chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.43.0 (#546)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.43.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 21:12:04 +02:00
h44z
3d923b328e password change UI (#543) (#548) 2025-10-15 21:11:40 +02:00
Christoph Haas
139fb17f98 redo UI screenshots, fix the responsiveness of the image slider for wgportal.org
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-10-12 15:48:08 +02:00
Christoph Haas
faf1d995a8 fix parsing IP addresses in UI (ip-address lib was updated to V10) 2025-10-12 15:20:38 +02:00
Christoph Haas
f53d0b3d7f add the possibility to debug oauth or oidc login issues (#541) 2025-10-12 15:09:40 +02:00
h44z
cdf3a49801 Cleanup route handling (#542)
* mikrotik: allow to set DNS, wip: handle routes in wg-controller

* replace old route handling for local controller

* cleanup route handling for local backend

* implement route handling for mikrotik controller
2025-10-12 14:31:19 +02:00
Christoph Haas
298c9405f6 add support for sending emails to peers without linked user accounts if their user-identifier is a valid email address 2025-10-12 14:31:01 +02:00
21 changed files with 606 additions and 248 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

@@ -73,6 +73,7 @@ mail:
auth_type: plain auth_type: plain
from: Wireguard Portal <noreply@wireguard.local> from: Wireguard Portal <noreply@wireguard.local>
link_only: false link_only: false
allow_peer_email: false
auth: auth:
oidc: [] oidc: []
@@ -387,6 +388,8 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
## Mail ## Mail
Options for configuring email notifications or sending peer configurations via email. Options for configuring email notifications or sending peer configurations via email.
By default, emails will only be sent to peers that have a valid user record linked.
To send emails to all peers that have a valid email-address as user-identifier, set `allow_peer_email` to `true`.
### `host` ### `host`
- **Default:** `127.0.0.1` - **Default:** `127.0.0.1`
@@ -424,6 +427,12 @@ Options for configuring email notifications or sending peer configurations via e
- **Default:** `false` - **Default:** `false`
- **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration. - **Description:** If `true`, emails only contain a link to WireGuard Portal, rather than attaching the full configuration.
### `allow_peer_email`
- **Default:** `false`
- **Description:** If `true`, and a peer has no valid user record linked, but the user-identifier of the peer is a valid email address, emails will be sent to that email address.
If false, and the peer has no valid user record linked, emails will not be sent.
If a peer has linked a valid user, the email address is always taken from the user record.
--- ---
## Auth ## Auth
@@ -503,13 +512,18 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
#### `registration_enabled` #### `registration_enabled`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, a new user will be created in WireGuard Portal if not already present. - **Description:** If `true`, a new user will be created in WireGuard Portal if not already present.
#### `log_user_info` #### `log_user_info`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging). - **Description:** If `true`, OIDC user data is logged at the trace level upon login (for debugging).
#### `log_sensitive_info`
- **Default:** `false`
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
--- ---
### OAuth ### OAuth
@@ -576,13 +590,18 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex. - `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
#### `registration_enabled` #### `registration_enabled`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, new users are created automatically on successful login. - **Description:** If `true`, new users are created automatically on successful login.
#### `log_user_info` #### `log_user_info`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, logs user info at the trace level upon login. - **Description:** If `true`, logs user info at the trace level upon login.
#### `log_sensitive_info`
- **Default:** `false`
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
--- ---
### LDAP ### LDAP
@@ -599,11 +618,11 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
- **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`). - **Description:** The LDAP server URL (e.g., `ldap://srv-ad01.company.local:389`).
#### `start_tls` #### `start_tls`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, use STARTTLS to secure the LDAP connection. - **Description:** If `true`, use STARTTLS to secure the LDAP connection.
#### `cert_validation` #### `cert_validation`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, validate the LDAP servers TLS certificate. - **Description:** If `true`, validate the LDAP servers TLS certificate.
#### `tls_certificate_path` #### `tls_certificate_path`
@@ -673,19 +692,19 @@ Below are the properties for each LDAP provider entry inside `auth.ldap`:
``` ```
#### `disable_missing` #### `disable_missing`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal. - **Description:** If `true`, any user **not** found in LDAP (during sync) is disabled in WireGuard Portal.
#### `auto_re_enable` #### `auto_re_enable`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again. - **Description:** If `true`, users that where disabled because they were missing (see `disable_missing`) will be re-enabled once they are found again.
#### `registration_enabled` #### `registration_enabled`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login. - **Description:** If `true`, new user accounts are created in WireGuard Portal upon first login.
#### `log_user_info` #### `log_user_info`
- **Default:** *(empty)* - **Default:** `false`
- **Description:** If `true`, logs LDAP user data at the trace level upon login. - **Description:** If `true`, logs LDAP user data at the trace level upon login.
--- ---

View File

@@ -68,7 +68,7 @@
} }
.tx-hero__image { .tx-hero__image {
max-width: 1000px; max-width: 1000px;
min-width: 600px; min-width: 0;
width: 100%; width: 100%;
height: auto; height: auto;
margin: 0 auto; margin: 0 auto;
@@ -218,7 +218,7 @@
.secondary-section .g .section .component-wrapper .responsive-grid .card { .secondary-section .g .section .component-wrapper .responsive-grid .card {
position: relative; position: relative;
background-color: #fff none repeat scroll 0% 0%; background-color: #fff;
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -363,7 +363,6 @@
<h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1> <h1>A beautiful and simple UI to manage your WireGuard peers and interfaces</h1>
<p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage <p>WireGuard Portal is an open source web-based user interface that makes it easy to setup and manage
WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p> WireGuard VPN connections. It's built on top of WireGuard's official <span class="em">wgctrl</span> library.</p>
</p>
<a <a
href="documentation/overview/" href="documentation/overview/"
title="Get Started" title="Get Started"

View File

@@ -4,12 +4,12 @@ export function ipToBigInt(ip) {
// Check if it's an IPv4 address // Check if it's an IPv4 address
if (ip.includes(".")) { if (ip.includes(".")) {
const addr = new Address4(ip) const addr = new Address4(ip)
return addr.bigInteger() return addr.bigInt()
} }
// Otherwise, assume it's an IPv6 address // Otherwise, assume it's an IPv6 address
const addr = new Address6(ip) const addr = new Address6(ip)
return addr.bigInteger() return addr.bigInt()
} }
export function humanFileSize(size) { export function humanFileSize(size) {

View File

@@ -221,6 +221,16 @@
"button-delete-text": "Passkey löschen. Sie können sich anschließend nicht mehr mit diesem Passkey anmelden.", "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-title": "Passkey registrieren",
"button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern." "button-register-text": "Einen neuen Passkey registrieren, um Ihr Konto zu sichern."
},
"password": {
"headline": "Passwort-Einstellungen",
"abstract": "Hier können Sie Ihr Passwort ändern.",
"current-label": "Aktuelles Passwort",
"new-label": "Neues Passwort",
"new-confirm-label": "Neues Passwort bestätigen",
"change-button-text": "Passwort ändern",
"invalid-confirm-label": "Passwörter stimmen nicht überein",
"weak-label": "Passwort ist zu schwach"
} }
}, },
"audit": { "audit": {

View File

@@ -221,6 +221,16 @@
"button-delete-text": "Delete the passkey. You will not be able to log in with this passkey anymore.", "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-title": "Register Passkey",
"button-register-text": "Register a new Passkey to secure your account." "button-register-text": "Register a new Passkey to secure your account."
},
"password": {
"headline": "Password Settings",
"abstract": "Here you can change your password.",
"current-label": "Current Password",
"new-label": "New Password",
"new-confirm-label": "Confirm New Password",
"change-button-text": "Change Password",
"invalid-confirm-label": "Passwords do not match",
"weak-label": "Password is too weak"
} }
}, },
"audit": { "audit": {

View File

@@ -151,6 +151,17 @@ export const profileStore = defineStore('profile', {
}) })
}) })
}, },
async changePassword(formData) {
this.fetching = true
let currentUser = authStore().user.Identifier
return apiWrapper.post(`${baseUrl}/${base64_url_encode(currentUser)}/change-password`, formData)
.then(this.fetching = false)
.catch(error => {
this.fetching = false;
console.log("Failed to change password for ", currentUser, ": ", error);
throw new Error(error);
});
},
async LoadPeers() { async LoadPeers() {
this.fetching = true this.fetching = true
let currentUser = authStore().user.Identifier let currentUser = authStore().user.Identifier

View File

@@ -1,8 +1,9 @@
<script setup> <script setup>
import {onMounted, ref} from "vue"; import {computed, onMounted, ref} from "vue";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { authStore } from "../stores/auth"; import { authStore } from "../stores/auth";
import {notify} from "@kyvg/vue3-notification";
const profile = profileStore() const profile = profileStore()
const settings = settingsStore() const settings = settingsStore()
@@ -34,6 +35,45 @@ async function saveRename(credential) {
console.error("Failed to rename credential:", error); console.error("Failed to rename credential:", error);
} }
} }
const pwFormData = ref({
OldPassword: '',
Password: '',
PasswordRepeat: '',
})
const passwordWeak = computed(() => {
return pwFormData.value.Password && pwFormData.value.Password.length > 0 && pwFormData.value.Password.length < settings.Setting('MinPasswordLength')
})
const passwordChangeAllowed = computed(() => {
return pwFormData.value.Password && pwFormData.value.Password.length >= settings.Setting('MinPasswordLength') &&
pwFormData.value.Password === pwFormData.value.PasswordRepeat &&
pwFormData.value.OldPassword && pwFormData.value.OldPassword.length > 0 && pwFormData.value.OldPassword !== pwFormData.value.Password;
})
const updatePassword = async () => {
try {
await profile.changePassword(pwFormData.value);
pwFormData.value.OldPassword = '';
pwFormData.value.Password = '';
pwFormData.value.PasswordRepeat = '';
notify({
title: "Password changed!",
text: "Your password has been changed successfully.",
type: 'success',
});
} catch (e) {
notify({
title: "Failed to update password!",
text: e.toString(),
type: 'error',
})
}
}
</script> </script>
<template> <template>
@@ -43,52 +83,45 @@ async function saveRename(credential) {
<p class="lead">{{ $t('settings.abstract') }}</p> <p class="lead">{{ $t('settings.abstract') }}</p>
<div v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"> <div class="card border-secondary p-5 mt-5" v-if="profile.user.Source === 'db'">
<div class="card border-secondary p-5" v-if="profile.user.ApiToken"> <h2 class="display-7">{{ $t('settings.password.headline') }}</h2>
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2> <p class="lead">{{ $t('settings.password.abstract') }}</p>
<p class="lead">{{ $t('settings.api.abstract') }}</p> <hr class="my-4">
<hr class="my-4">
<p>{{ $t('settings.api.active-description') }}</p> <div class="row">
<div class="row"> <div class="col-6">
<div class="col-6"> <div class="form-group">
<div class="form-group"> <label class="form-label mt-4" for="oldpw">{{ $t('settings.password.current-label') }}</label>
<label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label> <input id="oldpw" v-model="pwFormData.OldPassword" class="form-control" :class="{ 'is-invalid': pwFormData.Password && !pwFormData.OldPassword }" type="password">
<input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label>
<input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly>
</div>
</div> </div>
</div> </div>
<div class="row"> <div class="col-6">
<div class="col-12"> </div>
<div class="form-group"> </div>
<p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p> <div class="row">
</div> <div class="col-6">
<div class="form-group has-success">
<label class="form-label mt-4" for="newpw">{{ $t('settings.password.new-label') }}</label>
<input id="newpw" v-model="pwFormData.Password" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': pwFormData.Password !== '' && !passwordWeak }" type="password">
<div class="invalid-feedback" v-if="passwordWeak">{{ $t('settings.password.weak-label') }}</div>
</div> </div>
</div> </div>
<div class="row mt-5"> <div class="col-6">
<div class="col-6"> <div class="form-group">
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching"> <label class="form-label mt-4" for="confirmnewpw">{{ $t('settings.password.new-confirm-label') }}</label>
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }} <input id="confirmnewpw" v-model="pwFormData.PasswordRepeat" class="form-control" :class="{ 'is-invalid': pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat, 'is-valid': pwFormData.PasswordRepeat !== '' && pwFormData.Password === pwFormData.PasswordRepeat && !passwordWeak }" type="password">
</button> <div class="invalid-feedback" v-if="pwFormData.PasswordRepeat !== ''&& pwFormData.Password !== pwFormData.PasswordRepeat">{{ $t('settings.password.invalid-confirm-label') }}</div>
</div>
<div class="col-6">
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
</div> </div>
</div> </div>
</div> </div>
<div class="card border-secondary p-5" v-else> <div class="row mt-5">
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2> <div class="col-6">
<p class="lead">{{ $t('settings.api.abstract') }}</p> <button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="updatePassword" :disabled="profile.isFetching || !passwordChangeAllowed">
<hr class="my-4"> <i class="fa-solid fa-floppy-disk"></i> {{ $t('settings.password.change-button-text') }}
<p>{{ $t('settings.api.inactive-description') }}</p> </button>
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching"> </div>
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }} <div class="col-6">
</button> </div>
</div> </div>
</div> </div>
@@ -173,4 +206,53 @@ async function saveRename(credential) {
</div> </div>
</div> </div>
<div class="mt-5" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')">
<div class="card border-secondary p-5" v-if="profile.user.ApiToken">
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('settings.api.active-description') }}</p>
<div class="row">
<div class="col-6">
<div class="form-group">
<label class="form-label mt-4">{{ $t('settings.api.user-label') }}</label>
<input v-model="profile.user.Identifier" class="form-control" :placeholder="$t('settings.api.user-placeholder')" type="text" readonly>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label class="form-label mt-4">{{ $t('settings.api.token-label') }}</label>
<input v-model="profile.user.ApiToken" class="form-control" :placeholder="$t('settings.api.token-placeholder')" type="text" readonly>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="form-group">
<p class="form-label mt-4">{{ $t('settings.api.token-created-label') }} {{profile.user.ApiTokenCreated}}</p>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-6">
<button class="btn btn-primary" :title="$t('settings.api.button-disable-title')" @click.prevent="profile.disableApi()" :disabled="profile.isFetching">
<i class="fa-solid fa-minus-circle"></i> {{ $t('settings.api.button-disable-text') }}
</button>
</div>
<div class="col-6">
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
</div>
</div>
</div>
<div class="card border-secondary p-5" v-else>
<h2 class="display-7">{{ $t('settings.api.headline') }}</h2>
<p class="lead">{{ $t('settings.api.abstract') }}</p>
<hr class="my-4">
<p>{{ $t('settings.api.inactive-description') }}</p>
<button class="btn btn-primary" :title="$t('settings.api.button-enable-title')" @click.prevent="profile.enableApi()" :disabled="profile.isFetching">
<i class="fa-solid fa-plus-circle"></i> {{ $t('settings.api.button-enable-text') }}
</button>
</div>
</div>
</template> </template>

10
go.mod
View File

@@ -21,9 +21,9 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1 github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.42.0 golang.org/x/crypto v0.43.0
golang.org/x/oauth2 v0.31.0 golang.org/x/oauth2 v0.32.0
golang.org/x/sys v0.36.0 golang.org/x/sys v0.37.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
@@ -93,9 +93,9 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/mod v0.28.0 // indirect golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.44.0 // indirect golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect golang.org/x/tools v0.37.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect

20
go.sum
View File

@@ -262,8 +262,8 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -291,10 +291,10 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -324,8 +324,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.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.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -354,8 +354,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@@ -1550,6 +1550,38 @@
} }
} }
}, },
"/user/{id}/change-password": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Change the password for the given user.",
"operationId": "users_handleChangePasswordPost",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/{id}/interfaces": { "/user/{id}/interfaces": {
"get": { "get": {
"produces": [ "produces": [
@@ -2159,6 +2191,10 @@
} }
] ]
}, },
"UserDisplayName": {
"description": "the owner display name",
"type": "string"
},
"UserIdentifier": { "UserIdentifier": {
"description": "the owner", "description": "the owner",
"type": "string" "type": "string"

View File

@@ -322,6 +322,9 @@ definitions:
allOf: allOf:
- $ref: '#/definitions/model.ConfigOption-string' - $ref: '#/definitions/model.ConfigOption-string'
description: the routing table description: the routing table
UserDisplayName:
description: the owner display name
type: string
UserIdentifier: UserIdentifier:
description: the owner description: the owner
type: string type: string
@@ -1442,6 +1445,27 @@ paths:
summary: Enable the REST API for the given user. summary: Enable the REST API for the given user.
tags: tags:
- Users - Users
/user/{id}/change-password:
post:
operationId: users_handleChangePasswordPost
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Change the password for the given user.
tags:
- Users
/user/{id}/interfaces: /user/{id}/interfaces:
get: get:
operationId: users_handleInterfacesGet operationId: users_handleInterfacesGet

View File

@@ -17,11 +17,6 @@
"paths": { "paths": {
"/interface/all": { "/interface/all": {
"get": { "get": {
"security": [
{
"BasicAuth": []
}
],
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -52,16 +47,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/interface/by-id/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/interface/by-id/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -110,14 +105,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
},
"put": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"put": {
"description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", "description": "This endpoint updates an existing interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
"produces": [ "produces": [
"application/json" "application/json"
@@ -182,14 +177,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
},
"delete": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"delete": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -241,16 +236,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/interface/new": {
"post": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/interface/new": {
"post": {
"description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).", "description": "This endpoint creates a new interface with the provided data. All required fields must be filled (e.g. name, private key, public key, ...).",
"produces": [ "produces": [
"application/json" "application/json"
@@ -308,16 +303,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/interface/prepare": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/interface/prepare": {
"get": {
"description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).", "description": "This endpoint returns a new interface with default values (fresh key pair, valid name, new IP address pool, ...).",
"produces": [ "produces": [
"application/json" "application/json"
@@ -352,16 +347,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/metrics/by-interface/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/metrics/by-interface/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -410,16 +405,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/metrics/by-peer/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/metrics/by-peer/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -468,16 +463,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/metrics/by-user/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/metrics/by-user/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -526,16 +521,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/peer/by-id/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/by-id/{id}": {
"get": {
"description": "Normal users can only access their own records. Admins can access all records.", "description": "Normal users can only access their own records. Admins can access all records.",
"produces": [ "produces": [
"application/json" "application/json"
@@ -585,14 +580,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
},
"put": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"put": {
"description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).", "description": "Only admins can update existing records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
"produces": [ "produces": [
"application/json" "application/json"
@@ -657,14 +652,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
},
"delete": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"delete": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -716,16 +711,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/peer/by-interface/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/by-interface/{id}": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -765,16 +760,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/peer/by-user/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/by-user/{id}": {
"get": {
"description": "Normal users can only access their own records. Admins can access all records.", "description": "Normal users can only access their own records. Admins can access all records.",
"produces": [ "produces": [
"application/json" "application/json"
@@ -815,16 +810,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/peer/new": {
"post": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/new": {
"post": {
"description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).", "description": "Only admins can create new records. The peer record must contain all required fields (e.g., public key, allowed IPs).",
"produces": [ "produces": [
"application/json" "application/json"
@@ -882,16 +877,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/peer/prepare/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/peer/prepare/{id}": {
"get": {
"description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.", "description": "This endpoint is used to prepare a new peer record. The returned data contains a fresh key pair and valid ip address.",
"produces": [ "produces": [
"application/json" "application/json"
@@ -947,16 +942,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/provisioning/data/peer-config": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/provisioning/data/peer-config": {
"get": {
"description": "Normal users can only access their own record. Admins can access all records.", "description": "Normal users can only access their own record. Admins can access all records.",
"produces": [ "produces": [
"text/plain", "text/plain",
@@ -1013,16 +1008,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/provisioning/data/peer-qr": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/provisioning/data/peer-qr": {
"get": {
"description": "Normal users can only access their own record. Admins can access all records.", "description": "Normal users can only access their own record. Admins can access all records.",
"produces": [ "produces": [
"image/png", "image/png",
@@ -1079,16 +1074,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/provisioning/data/user-info": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/provisioning/data/user-info": {
"get": {
"description": "Normal users can only access their own record. Admins can access all records.", "description": "Normal users can only access their own record. Admins can access all records.",
"produces": [ "produces": [
"application/json" "application/json"
@@ -1149,16 +1144,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/provisioning/new-peer": {
"post": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/provisioning/new-peer": {
"post": {
"description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.", "description": "Normal users can only create new peers if self provisioning is allowed. Admins can always add new peers.",
"produces": [ "produces": [
"application/json" "application/json"
@@ -1216,16 +1211,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/user/all": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/user/all": {
"get": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -1256,16 +1251,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/user/by-id/{id}": {
"get": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/user/by-id/{id}": {
"get": {
"description": "Normal users can only access their own record. Admins can access all records.", "description": "Normal users can only access their own record. Admins can access all records.",
"produces": [ "produces": [
"application/json" "application/json"
@@ -1315,14 +1310,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
},
"put": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"put": {
"description": "Only admins can update existing records.", "description": "Only admins can update existing records.",
"produces": [ "produces": [
"application/json" "application/json"
@@ -1387,14 +1382,14 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
},
"delete": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
},
"delete": {
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -1446,16 +1441,16 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
}
},
"/user/new": {
"post": {
"security": [ "security": [
{ {
"BasicAuth": [] "BasicAuth": []
} }
], ]
}
},
"/user/new": {
"post": {
"description": "Only admins can create new records.", "description": "Only admins can create new records.",
"produces": [ "produces": [
"application/json" "application/json"
@@ -1513,7 +1508,12 @@
"$ref": "#/definitions/models.Error" "$ref": "#/definitions/models.Error"
} }
} }
} },
"security": [
{
"BasicAuth": []
}
]
} }
} }
}, },

View File

@@ -2,6 +2,8 @@ package backend
import ( import (
"context" "context"
"fmt"
"strings"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
@@ -70,6 +72,44 @@ func (u UserService) DeactivateApi(ctx context.Context, id domain.UserIdentifier
return u.users.DeactivateApi(ctx, id) return u.users.DeactivateApi(ctx, id)
} }
func (u UserService) ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error) {
oldPassword = strings.TrimSpace(oldPassword)
newPassword = strings.TrimSpace(newPassword)
if newPassword == "" {
return nil, fmt.Errorf("new password must not be empty")
}
// ensure that the new password is different from the old one
if oldPassword == newPassword {
return nil, fmt.Errorf("new password must be different from the old one")
}
user, err := u.users.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
// ensure that the user uses the database backend; otherwise we can't change the password
if user.Source != domain.UserSourceDatabase {
return nil, fmt.Errorf("user source %s does not support password changes", user.Source)
}
// validate old password
if user.CheckPassword(oldPassword) != nil {
return nil, fmt.Errorf("current password is invalid")
}
user.Password = domain.PrivateString(newPassword)
// ensure that the new password is strong enough
if err := user.HasWeakPassword(u.cfg.Auth.MinPasswordLength); err != nil {
return nil, err
}
return u.users.UpdateUser(ctx, user)
}
func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) { func (u UserService) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
return u.wg.GetUserPeers(ctx, id) return u.wg.GetUserPeers(ctx, id)
} }

View File

@@ -28,6 +28,8 @@ type UserService interface {
ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) ActivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
// DeactivateApi disables the API for the user with the given id. // DeactivateApi disables the API for the user with the given id.
DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
// ChangePassword changes the password for the user with the given id.
ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error)
// GetUserPeers returns all peers for the given user. // GetUserPeers returns all peers for the given user.
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
// GetUserPeerStats returns all peer stats for the given user. // GetUserPeerStats returns all peer stats for the given user.
@@ -75,6 +77,7 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password", e.handleChangePasswordPost())
} }
// handleAllGet returns a gorm Handler function. // handleAllGet returns a gorm Handler function.
@@ -391,3 +394,68 @@ func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc {
respond.JSON(w, http.StatusOK, model.NewUser(user, false)) respond.JSON(w, http.StatusOK, model.NewUser(user, false))
} }
} }
// handleChangePasswordPost returns a gorm Handler function.
//
// @ID users_handleChangePasswordPost
// @Tags Users
// @Summary Change the password for the given user.
// @Produce json
// @Success 200 {object} model.User
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/{id}/change-password [post]
func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userId := Base64UrlDecode(request.Path(r, "id"))
if userId == "" {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
return
}
var passwordData struct {
OldPassword string `json:"OldPassword"`
Password string `json:"Password"`
PasswordRepeat string `json:"PasswordRepeat"`
}
if err := request.BodyJson(r, &passwordData); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
if passwordData.OldPassword == "" {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "old password missing"})
return
}
if passwordData.Password == "" {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "new password missing"})
return
}
if passwordData.OldPassword == passwordData.Password {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "password did not change"})
return
}
if passwordData.Password != passwordData.PasswordRepeat {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "password mismatch"})
return
}
user, err := e.userService.ChangePassword(r.Context(), domain.UserIdentifier(userId),
passwordData.OldPassword, passwordData.Password)
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
}
}

View File

@@ -19,15 +19,16 @@ import (
// PlainOauthAuthenticator is an authenticator that uses OAuth for authentication. // PlainOauthAuthenticator is an authenticator that uses OAuth for authentication.
// User information is retrieved from the specified user info endpoint. // User information is retrieved from the specified user info endpoint.
type PlainOauthAuthenticator struct { type PlainOauthAuthenticator struct {
name string name string
cfg *oauth2.Config cfg *oauth2.Config
userInfoEndpoint string userInfoEndpoint string
client *http.Client client *http.Client
userInfoMapping config.OauthFields userInfoMapping config.OauthFields
userAdminMapping *config.OauthAdminMapping userAdminMapping *config.OauthAdminMapping
registrationEnabled bool registrationEnabled bool
userInfoLogging bool userInfoLogging bool
allowedDomains []string sensitiveInfoLogging bool
allowedDomains []string
} }
func newPlainOauthAuthenticator( func newPlainOauthAuthenticator(
@@ -57,6 +58,7 @@ func newPlainOauthAuthenticator(
provider.userAdminMapping = &cfg.AdminMapping provider.userAdminMapping = &cfg.AdminMapping
provider.registrationEnabled = cfg.RegistrationEnabled provider.registrationEnabled = cfg.RegistrationEnabled
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
return provider, nil return provider, nil
@@ -110,6 +112,10 @@ func (p PlainOauthAuthenticator) GetUserInfo(
response, err := p.client.Do(req) response, err := p.client.Do(req)
if err != nil { if err != nil {
if p.sensitiveInfoLogging {
slog.Debug("OAuth: failed to get user info", "endpoint", p.userInfoEndpoint,
"token", token, "error", err)
}
return nil, fmt.Errorf("failed to get user info: %w", err) return nil, fmt.Errorf("failed to get user info: %w", err)
} }
defer internal.LogClose(response.Body) defer internal.LogClose(response.Body)
@@ -121,11 +127,15 @@ func (p PlainOauthAuthenticator) GetUserInfo(
var userFields map[string]any var userFields map[string]any
err = json.Unmarshal(contents, &userFields) err = json.Unmarshal(contents, &userFields)
if err != nil { if err != nil {
if p.sensitiveInfoLogging {
slog.Debug("OAuth: failed to parse user info", "endpoint", p.userInfoEndpoint,
"token", token, "contents", contents, "error", err)
}
return nil, fmt.Errorf("failed to parse user info: %w", err) return nil, fmt.Errorf("failed to parse user info: %w", err)
} }
if p.userInfoLogging { if p.userInfoLogging {
slog.Debug("OAuth user info", slog.Debug("OAuth: user info debug",
"source", p.name, "source", p.name,
"info", string(contents)) "info", string(contents))
} }

View File

@@ -16,15 +16,16 @@ import (
// OidcAuthenticator is an authenticator for OpenID Connect providers. // OidcAuthenticator is an authenticator for OpenID Connect providers.
type OidcAuthenticator struct { type OidcAuthenticator struct {
name string name string
provider *oidc.Provider provider *oidc.Provider
verifier *oidc.IDTokenVerifier verifier *oidc.IDTokenVerifier
cfg *oauth2.Config cfg *oauth2.Config
userInfoMapping config.OauthFields userInfoMapping config.OauthFields
userAdminMapping *config.OauthAdminMapping userAdminMapping *config.OauthAdminMapping
registrationEnabled bool registrationEnabled bool
userInfoLogging bool userInfoLogging bool
allowedDomains []string sensitiveInfoLogging bool
allowedDomains []string
} }
func newOidcAuthenticator( func newOidcAuthenticator(
@@ -58,6 +59,7 @@ func newOidcAuthenticator(
provider.userAdminMapping = &cfg.AdminMapping provider.userAdminMapping = &cfg.AdminMapping
provider.registrationEnabled = cfg.RegistrationEnabled provider.registrationEnabled = cfg.RegistrationEnabled
provider.userInfoLogging = cfg.LogUserInfo provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
return provider, nil return provider, nil
@@ -102,24 +104,40 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
) { ) {
rawIDToken, ok := token.Extra("id_token").(string) rawIDToken, ok := token.Extra("id_token").(string)
if !ok { if !ok {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: token does not contain id_token", "token", token, "nonce", nonce)
}
return nil, errors.New("token does not contain id_token") return nil, errors.New("token does not contain id_token")
} }
idToken, err := o.verifier.Verify(ctx, rawIDToken) idToken, err := o.verifier.Verify(ctx, rawIDToken)
if err != nil { if err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to validate id_token", "token", token, "id_token", rawIDToken, "nonce", nonce,
"error",
err)
}
return nil, fmt.Errorf("failed to validate id_token: %w", err) return nil, fmt.Errorf("failed to validate id_token: %w", err)
} }
if idToken.Nonce != nonce { if idToken.Nonce != nonce {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: id_token nonce mismatch", "token", token, "id_token", idToken, "nonce", nonce)
}
return nil, errors.New("nonce mismatch") return nil, errors.New("nonce mismatch")
} }
var tokenFields map[string]any var tokenFields map[string]any
if err = idToken.Claims(&tokenFields); err != nil { if err = idToken.Claims(&tokenFields); err != nil {
if o.sensitiveInfoLogging {
slog.Debug("OIDC: failed to parse extra claims", "token", token, "id_token", idToken, "nonce", nonce,
"error",
err)
}
return nil, fmt.Errorf("failed to parse extra claims: %w", err) return nil, fmt.Errorf("failed to parse extra claims: %w", err)
} }
if o.userInfoLogging { if o.userInfoLogging {
contents, _ := json.Marshal(tokenFields) contents, _ := json.Marshal(tokenFields)
slog.Debug("OIDC user info", slog.Debug("OIDC: user info debug",
"source", o.name, "source", o.name,
"info", string(contents)) "info", string(contents))
} }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/mail"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
@@ -101,29 +102,15 @@ func (m Manager) SendPeerEmail(ctx context.Context, linkOnly bool, style string,
} }
if peer.UserIdentifier == "" { if peer.UserIdentifier == "" {
slog.Debug("skipping peer email", return fmt.Errorf("peer %s has no user linked, no email is sent", peerId)
"peer", peerId,
"reason", "no user linked")
continue
} }
user, err := m.users.GetUser(ctx, peer.UserIdentifier) email, user := m.resolveEmail(ctx, peer)
if err != nil { if email == "" {
slog.Debug("skipping peer email", return fmt.Errorf("peer %s has no valid email address, no email is sent", peerId)
"peer", peerId,
"reason", "unable to fetch user",
"error", err)
continue
} }
if user.Email == "" { err = m.sendPeerEmail(ctx, linkOnly, style, &user, peer)
slog.Debug("skipping peer email",
"peer", peerId,
"reason", "user has no mail address")
continue
}
err = m.sendPeerEmail(ctx, linkOnly, style, user, peer)
if err != nil { if err != nil {
return fmt.Errorf("failed to send peer email for %s: %w", peerId, err) return fmt.Errorf("failed to send peer email for %s: %w", peerId, err)
} }
@@ -194,3 +181,37 @@ func (m Manager) sendPeerEmail(
return nil return nil
} }
func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, domain.User) {
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
if err != nil {
if m.cfg.Mail.AllowPeerEmail {
_, err := mail.ParseAddress(string(peer.UserIdentifier)) // test if the user identifier is a valid email address
if err == nil {
slog.Debug("peer email: using user-identifier as email",
"peer", peer.Identifier, "email", peer.UserIdentifier)
return string(peer.UserIdentifier), domain.User{}
} else {
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "peer has no user linked and user-identifier is not a valid email address")
return "", domain.User{}
}
} else {
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "user has no user linked")
return "", domain.User{}
}
}
if user.Email == "" {
slog.Debug("peer email: skipping peer email",
"peer", peer.Identifier,
"reason", "user has no mail address")
return "", domain.User{}
}
slog.Debug("peer email: using user email", "peer", peer.Identifier, "email", user.Email)
return user.Email, *user
}

View File

@@ -211,6 +211,10 @@ type OpenIDConnectProvider struct {
// If LogUserInfo is set to true, the user info retrieved from the OIDC provider will be logged in trace level. // If LogUserInfo is set to true, the user info retrieved from the OIDC provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"` LogUserInfo bool `yaml:"log_user_info"`
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
} }
// OAuthProvider contains the configuration for the OAuth provider. // OAuthProvider contains the configuration for the OAuth provider.
@@ -252,6 +256,10 @@ type OAuthProvider struct {
// If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level. // If LogUserInfo is set to true, the user info retrieved from the OAuth provider will be logged in trace level.
LogUserInfo bool `yaml:"log_user_info"` LogUserInfo bool `yaml:"log_user_info"`
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
} }
// WebauthnConfig contains the configuration for the WebAuthn authenticator. // WebauthnConfig contains the configuration for the WebAuthn authenticator.

View File

@@ -41,4 +41,6 @@ type MailConfig struct {
From string `yaml:"from"` From string `yaml:"from"`
// LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration // LinkOnly specifies whether emails should only contain a link to WireGuard Portal or attach the full configuration
LinkOnly bool `yaml:"link_only"` LinkOnly bool `yaml:"link_only"`
// AllowPeerEmail specifies whether emails should be sent to peers which have no valid user account linked, but an email address is set as "user".
AllowPeerEmail bool `yaml:"allow_peer_email"`
} }