Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot]
b080002c36 chore(deps): bump the patch group with 3 updates
Bumps the patch group with 3 updates: [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn), [golang.org/x/crypto](https://github.com/golang/crypto) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `github.com/go-webauthn/webauthn` from 0.16.3 to 0.16.4
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.3...v0.16.4)

Updates `golang.org/x/crypto` from 0.49.0 to 0.50.0
- [Commits](https://github.com/golang/crypto/compare/v0.49.0...v0.50.0)

Updates `golang.org/x/sys` from 0.42.0 to 0.43.0
- [Commits](https://github.com/golang/sys/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.16.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
- dependency-name: golang.org/x/crypto
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: patch
- dependency-name: golang.org/x/sys
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 15:01:46 +00:00
dependabot[bot]
b44b79d42c chore(deps): bump the patch group with 2 updates (#664)
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 the patch group with 2 updates: [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) and [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn).


Updates `github.com/go-playground/validator/v10` from 10.30.1 to 10.30.2
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.30.1...v10.30.2)

Updates `github.com/go-webauthn/webauthn` from 0.16.1 to 0.16.3
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Changelog](https://github.com/go-webauthn/webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.1...v0.16.3)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.16.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-12 13:25:44 +02:00
Michael Tupitsyn
71806455dd OIDC - support IdP logout (#670)
* OIDC - support IdP logout

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Add support of logout_idp_session parameter

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Fix merge conflict issue

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Restore original package-lock.json

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Cleanup

---------

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>
Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-04-12 13:18:04 +02:00
Michael Tupitsyn
9b437205b1 Add support for auth.oidc.allowed_user_groups (#667) (#668)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>
2026-04-11 18:24:18 +02:00
h44z
401642701a feat: improve pagination (#662) (#663)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2026-04-07 22:17:53 +02:00
Mykhailo Roit
72f9123592 Add test-in-docker target to Makefile (#659)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* Add test-in-docker target to Makefile

Add a target to run tests in Docker for non-Linux environments.

* Add GOVERSION variable to Makefile

* fix: update test-in-docker command to use user permissions

* Fix docker command syntax in Makefile
2026-04-03 22:01:07 +02:00
Mykhailo Roit
0e9e9d697f fix: "created_at" for users (#656)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* fix: created_at for users

* added tests for: created_at for users

* cleanup fixes

---------

Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-04-01 11:58:22 +02:00
Christoph
87bfd5b23a feat: allow encrypting user api token using gorm serializer 2026-04-01 11:42:07 +02:00
h44z
920806b231 chore: update frontend deps (#657)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2026-04-01 00:20:35 +02:00
36 changed files with 2013 additions and 1404 deletions

View File

@@ -1,7 +1,8 @@
# Go parameters
GOCMD=go
GOVERSION=1.25
MODULENAME=github.com/h44z/wg-portal
GOFILES:=$(shell go list ./... | grep -v /vendor/)
GOFILES=$(shell go list ./... | grep -v /vendor/)
BUILDDIR=dist
BINARIES=$(subst cmd/,,$(wildcard cmd/*))
IMAGE=h44z/wg-portal
@@ -51,6 +52,11 @@ format:
.PHONY: test
test: test-vet test-race
#> test-in-docker: Run tests in Docker (for non-Linux environments e.g. MacOS)
.PHONY: test-in-docker
test-in-docker:
docker run --rm -u $(shell id -u):$(shell id -g) -e HOME=/tmp -v $(PWD):/app -w /app golang:$(GOVERSION) make test
#< test-vet: Static code analysis
.PHONY: test-vet
test-vet: build-dependencies

View File

@@ -47,6 +47,7 @@ auth:
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
registration_enabled: true
logout_idp_session: true
- id: oidc2
provider_name: google2
display_name: Login with</br>Google2
@@ -57,6 +58,7 @@ auth:
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
registration_enabled: true
logout_idp_session: true
oauth:
- id: google_plain_oauth
provider_name: google3

View File

@@ -144,6 +144,9 @@ auth:
extra_scopes:
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
allowed_user_groups:
- the-admin-group
- vpn-users
field_map:
user_identifier: sub
email: email
@@ -201,6 +204,9 @@ auth:
- email
- profile
- i-want-some-groups
allowed_user_groups:
- admin-group-name
- vpn-users
field_map:
email: email
firstname: name

View File

@@ -561,6 +561,10 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Default:** *(empty)*
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
#### `allowed_user_groups`
- **Default:** *(empty)*
- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values.
#### `field_map`
- **Default:** *(empty)*
- **Description:** Maps OIDC claims to WireGuard Portal user fields.
@@ -596,6 +600,10 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **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.
#### `logout_idp_session`
- **Default:** `true`
- **Description:** If `true` (default), WireGuard Portal will redirect the user to the OIDC provider's `end_session_endpoint` after local logout, terminating the session at the IdP as well. Set to `false` to only invalidate the local WireGuard Portal session without touching the IdP session.
---
### OAuth
@@ -639,6 +647,10 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- **Default:** *(empty)*
- **Description:** A list of allowlisted domains. Only users with email addresses in these domains can log in or register. This is useful for restricting access to specific organizations or groups.
#### `allowed_user_groups`
- **Default:** *(empty)*
- **Description:** A list of allowlisted user groups. If configured, at least one entry in the mapped `user_groups` claim must match one of these values.
#### `field_map`
- **Default:** *(empty)*
- **Description:** Maps OAuth attributes to WireGuard Portal fields.

View File

@@ -66,6 +66,40 @@ auth:
- "outlook.com"
```
#### Limiting Login to Specific User Groups
You can limit the login to specific user groups by setting the `allowed_user_groups` property for OAuth2 or OIDC providers.
If this property is not empty, the user's `user_groups` claim must contain at least one matching group.
To use this feature, ensure your group claim is mapped via `field_map.user_groups`.
```yaml
auth:
oidc:
- provider_name: "oidc1"
# ... other settings
allowed_user_groups:
- "wg-users"
- "wg-admins"
field_map:
user_groups: "groups"
```
If `allowed_user_groups` is configured and the authenticated user has no matching group in `user_groups`, login is denied.
Minimal deny-by-group example:
```yaml
auth:
oauth:
- provider_name: "oauth1"
# ... other settings
allowed_user_groups:
- "vpn-users"
field_map:
user_groups: "groups"
```
#### Limit Login to Existing Users
You can limit the login to existing users only by setting the `registration_enabled` property to `false` for OAuth2 or OIDC providers.

View File

@@ -14,7 +14,7 @@
let WGPORTAL_SITE_TITLE="WireGuard Portal";
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";
</script>
<script src="/api/v0/config/frontend.js"></script>
<script src="/api/v0/config/frontend.js" vite-ignore></script>
</head>
<body class="d-flex flex-column min-vh-100">
<noscript>

File diff suppressed because it is too large Load Diff

View File

@@ -9,28 +9,28 @@
},
"dependencies": {
"@fontsource/nunito-sans": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.1.0",
"@fortawesome/fontawesome-free": "^7.2.0",
"@kyvg/vue3-notification": "^3.4.2",
"@popperjs/core": "^2.11.8",
"@simplewebauthn/browser": "^13.2.2",
"@vojtechlanka/vue-tags-input": "^3.1.1",
"@simplewebauthn/browser": "^13.3.0",
"@vojtechlanka/vue-tags-input": "^3.1.2",
"bootstrap": "^5.3.8",
"bootswatch": "^5.3.8",
"cidr-tools": "^11.0.3",
"cidr-tools": "^11.3.2",
"flag-icons": "^7.5.0",
"ip-address": "^10.1.0",
"is-cidr": "^6.0.1",
"is-cidr": "^6.0.3",
"is-ip": "^5.0.1",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue": "^3.5.31",
"vue-i18n": "^11.3.0",
"vue-prism-component": "github:h44z/vue-prism-component",
"vue-router": "^4.6.3"
"vue-router": "^5.0.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"sass-embedded": "^1.93.3",
"vite": "^7.2.7"
"@vitejs/plugin-vue": "^6.0.5",
"sass-embedded": "^1.98.0",
"vite": "^8.0.3"
}
}

View File

@@ -26,13 +26,13 @@
display:block;
}
.modal.show {
opacity: 1;
opacity: 1.0;
}
.modal-backdrop {
background-color: rgba(0,0,0,0.6) !important;
}
.modal-backdrop.show {
opacity: 1 !important;
opacity: 1.0 !important;
}
</style>

View File

@@ -0,0 +1,121 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalCount: {
type: Number,
required: true
},
pageSize: {
type: Number,
required: true
},
onGotoPage: {
type: Function,
required: true
},
onNextPage: {
type: Function,
required: true
},
onPrevPage: {
type: Function,
required: true
},
hasNextPage: {
type: Boolean,
required: true
},
hasPrevPage: {
type: Boolean,
required: true
}
});
const totalPages = computed(() => Math.ceil(props.totalCount / props.pageSize));
const pages = computed(() => {
const current = props.currentPage;
const last = totalPages.value;
const delta = 2; // Number of pages to show before and after current page
const range = [];
const rangeWithDots = [];
// If total pages is small, just show all pages
if (last <= 7) {
for (let i = 1; i <= last; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
return rangeWithDots;
}
// Calculate the range around the current page
let start = Math.max(2, current - delta);
let end = Math.min(last - 1, current + delta);
// Adjust range to always show a consistent number of pages if possible
if (current <= delta + 2) {
end = 2 + delta * 2;
} else if (current >= last - delta - 1) {
start = last - delta * 2 - 1;
}
// Add dots before the range if needed
if (start > 2) {
rangeWithDots.push({ type: 'page', value: 1 });
rangeWithDots.push({ type: 'dots', value: 'dots-start' });
} else {
for (let i = 1; i < start; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
}
// Add the central range
for (let i = start; i <= end; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
// Add dots after the range if needed
if (end < last - 1) {
rangeWithDots.push({ type: 'dots', value: 'dots-end' });
rangeWithDots.push({ type: 'page', value: last });
} else {
for (let i = end + 1; i <= last; i++) {
rangeWithDots.push({ type: 'page', value: i });
}
}
return rangeWithDots;
});
</script>
<template>
<ul class="pagination pagination-sm mb-0" v-if="totalPages > 1">
<li :class="{ disabled: !hasPrevPage }" class="page-item">
<a class="page-link" href="#" @click.prevent="hasPrevPage && onPrevPage()">&laquo;</a>
</li>
<li v-for="item in pages" :key="item.type === 'page' ? item.value : item.value" :class="{ active: currentPage === item.value, disabled: item.type === 'dots' }" class="page-item">
<a v-if="item.type === 'page'" class="page-link" href="#" @click.prevent="onGotoPage(item.value)">{{ item.value }}</a>
<span v-else class="page-link">...</span>
</li>
<li :class="{ disabled: !hasNextPage }" class="page-item">
<a class="page-link" href="#" @click.prevent="hasNextPage && onNextPage()">&raquo;</a>
</li>
</ul>
</template>
<style scoped>
.page-link {
cursor: pointer;
}
.page-item.disabled .page-link {
cursor: default;
}
</style>

View File

@@ -1,7 +1,6 @@
import {createRouter, createWebHashHistory} from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import InterfaceView from '../views/InterfaceView.vue'
import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security'
@@ -20,11 +19,6 @@ const router = createRouter({
name: 'login',
component: LoginView
},
{
path: '/interface',
name: 'interface',
component: InterfaceView
},
{
path: '/interfaces',
name: 'interfaces',

View File

@@ -11,7 +11,6 @@ export const auditStore = defineStore('audit', {
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
@@ -41,33 +40,22 @@ export const auditStore = defineStore('audit', {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
if (this.hasNextPage) {
this.pageOffset += this.pageSize
}
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
}
},
setEntries(entries) {
this.entries = entries
this.calculatePages()
this.fetching = false
},
async LoadEntries() {

View File

@@ -108,12 +108,19 @@ export const authStore = defineStore('auth',{
this.setUserInfo(null)
this.ResetReturnUrl() // just to be sure^^
let logoutResponse = null
try {
await apiWrapper.post(`/auth/logout`)
logoutResponse = await apiWrapper.post(`/auth/logout`)
} catch (e) {
console.log("Logout request failed:", e)
}
const redirectUrl = logoutResponse?.RedirectUrl
if (redirectUrl) {
window.location.href = redirectUrl
return
}
notify({
title: "Logged Out",
text: "Logout successful!",

View File

@@ -19,7 +19,6 @@ export const peerStore = defineStore('peers', {
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending
@@ -87,33 +86,22 @@ export const peerStore = defineStore('peers', {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
if (this.hasNextPage) {
this.pageOffset += this.pageSize
}
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
}
},
setPeers(peers) {
this.peers = peers
this.calculatePages()
this.fetching = false
this.trafficStats = {}
},

View File

@@ -20,7 +20,6 @@ export const profileStore = defineStore('profile', {
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
sortKey: 'IsConnected', // Default sort key
sortOrder: -1, // 1 for ascending, -1 for descending
@@ -80,29 +79,19 @@ export const profileStore = defineStore('profile', {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredPeerCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
if (this.hasNextPage) {
this.pageOffset += this.pageSize
}
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
}
},
setPeers(peers) {
this.peers = peers

View File

@@ -12,7 +12,6 @@ export const userStore = defineStore('users', {
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
@@ -43,33 +42,22 @@ export const userStore = defineStore('users', {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
if (this.hasNextPage) {
this.pageOffset += this.pageSize
}
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
}
},
setUsers(users) {
this.users = users
this.calculatePages()
this.fetching = false
},
setUserPeers(peers) {

View File

@@ -1,6 +1,7 @@
<script setup>
import { onMounted } from "vue";
import {auditStore} from "@/stores/audit";
import Pagination from "@/components/Pagination.vue";
const audit = auditStore()
@@ -60,28 +61,24 @@ onMounted(async () => {
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:audit.pageOffset===0}" class="page-item">
<a class="page-link" @click="audit.previousPage">&laquo;</a>
</li>
<li v-for="page in audit.pages" :key="page" :class="{active:audit.currentPage===page}" class="page-item">
<a class="page-link" @click="audit.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!audit.hasNextPage}" class="page-item">
<a class="page-link" @click="audit.nextPage">&raquo;</a>
</li>
</ul>
<div class="col-12 col-md-6">
<Pagination
:currentPage="audit.currentPage"
:totalCount="audit.FilteredCount"
:pageSize="audit.pageSize"
:hasNextPage="audit.hasNextPage"
:hasPrevPage="audit.hasPrevPage"
:onGotoPage="audit.gotoPage"
:onNextPage="audit.nextPage"
:onPrevPage="audit.previousPage"
/>
</div>
<div class="col-6">
<div class="col-12 col-md-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @change="audit.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
@@ -92,5 +89,4 @@ onMounted(async () => {
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,9 +1,10 @@
<script setup>
import PeerViewModal from "../components/PeerViewModal.vue";
import PeerEditModal from "../components/PeerEditModal.vue";
import PeerMultiCreateModal from "../components/PeerMultiCreateModal.vue";
import InterfaceEditModal from "../components/InterfaceEditModal.vue";
import InterfaceViewModal from "../components/InterfaceViewModal.vue";
import PeerViewModal from "@/components/PeerViewModal.vue";
import PeerEditModal from "@/components/PeerEditModal.vue";
import PeerMultiCreateModal from "@/components/PeerMultiCreateModal.vue";
import InterfaceEditModal from "@/components/InterfaceEditModal.vue";
import InterfaceViewModal from "@/components/InterfaceViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import {computed, onMounted, ref} from "vue";
import {peerStore} from "@/stores/peers";
@@ -482,26 +483,23 @@ onMounted(async () => {
<hr v-if="interfaces.Count!==0">
<div v-if="interfaces.Count!==0" class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:peers.pageOffset===0}" class="page-item">
<a class="page-link" @click="peers.previousPage">&laquo;</a>
</li>
<li v-for="page in peers.pages" :key="page" :class="{active:peers.currentPage===page}" class="page-item">
<a class="page-link" @click="peers.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!peers.hasNextPage}" class="page-item">
<a class="page-link" @click="peers.nextPage">&raquo;</a>
</li>
</ul>
<div class="col-12 col-md-6">
<Pagination
:currentPage="peers.currentPage"
:totalCount="peers.FilteredCount"
:pageSize="peers.pageSize"
:hasNextPage="peers.hasNextPage"
:hasPrevPage="peers.hasPrevPage"
:onGotoPage="peers.gotoPage"
:onNextPage="peers.nextPage"
:onPrevPage="peers.previousPage"
/>
</div>
<div class="col-6">
<div class="col-12 col-md-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @change="peers.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>

View File

@@ -6,6 +6,7 @@ import { useI18n } from "vue-i18n";
import { profileStore } from "@/stores/profile";
import { peerStore } from "@/stores/peers";
import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
import Pagination from "@/components/Pagination.vue";
import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils";
@@ -66,7 +67,6 @@ onMounted(async () => {
await profile.LoadPeers()
await profile.LoadStats()
await profile.LoadInterfaces()
await profile.calculatePages(); // Forces to show initial page number
})
</script>
@@ -185,36 +185,33 @@ onMounted(async () => {
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{ disabled: profile.pageOffset === 0 }" class="page-item">
<a class="page-link" @click="profile.previousPage">&laquo;</a>
</li>
<li v-for="page in profile.pages" :key="page" :class="{ active: profile.currentPage === page }" class="page-item">
<a class="page-link" @click="profile.gotoPage(page)">{{ page }}</a>
</li>
<li :class="{ disabled: !profile.hasNextPage }" class="page-item">
<a class="page-link" @click="profile.nextPage">&raquo;</a>
</li>
</ul>
<div class="col-12 col-md-6">
<Pagination
:currentPage="profile.currentPage"
:totalCount="profile.FilteredPeerCount"
:pageSize="profile.pageSize"
:hasNextPage="profile.hasNextPage"
:hasPrevPage="profile.hasPrevPage"
:onGotoPage="profile.gotoPage"
:onNextPage="profile.nextPage"
:onPrevPage="profile.previousPage"
/>
</div>
<div class="col-6">
<div class="col-12 col-md-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">
{{ $t('general.pagination.size')}}:
</label>
<div class="col-sm-6">
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @change="profile.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div></template>

View File

@@ -1,8 +1,9 @@
<script setup>
import {userStore} from "@/stores/users";
import {ref, onMounted, computed} from "vue";
import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue";
import UserEditModal from "@/components/UserEditModal.vue";
import UserViewModal from "@/components/UserViewModal.vue";
import Pagination from "@/components/Pagination.vue";
import {useI18n} from "vue-i18n";
const users = userStore()
@@ -165,28 +166,24 @@ onMounted(() => {
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:users.pageOffset===0}" class="page-item">
<a class="page-link" @click="users.previousPage">&laquo;</a>
</li>
<li v-for="page in users.pages" :key="page" :class="{active:users.currentPage===page}" class="page-item">
<a class="page-link" @click="users.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!users.hasNextPage}" class="page-item">
<a class="page-link" @click="users.nextPage">&raquo;</a>
</li>
</ul>
<div class="col-12 col-md-6">
<Pagination
:currentPage="users.currentPage"
:totalCount="users.FilteredCount"
:pageSize="users.pageSize"
:hasNextPage="users.hasNextPage"
:hasPrevPage="users.hasPrevPage"
:onGotoPage="users.gotoPage"
:onNextPage="users.nextPage"
:onPrevPage="users.previousPage"
/>
</div>
<div class="col-6">
<div class="col-12 col-md-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @change="users.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
@@ -197,5 +194,4 @@ onMounted(() => {
</div>
</div>
</div>
</div>
</template>

22
go.mod
View File

@@ -9,8 +9,8 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.16.1
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.16.4
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/prometheus-community/pro-bing v0.8.0
@@ -22,9 +22,9 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.42.0
golang.org/x/sys v0.43.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
@@ -41,7 +41,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.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
@@ -61,7 +61,7 @@ require (
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.2 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
@@ -81,12 +81,14 @@ require (
github.com/microsoft/go-mssqldb v1.9.6 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/tinylib/msgp v1.6.3 // 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
@@ -94,11 +96,11 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.68.0 // indirect

44
go.sum
View File

@@ -48,8 +48,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.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -97,18 +97,18 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/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-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q=
github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -195,6 +195,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
@@ -234,6 +236,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA=
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
@@ -274,8 +278,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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -284,8 +288,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -303,8 +307,8 @@ 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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -336,8 +340,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.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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -366,16 +370,16 @@ 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.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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=

View File

@@ -232,21 +232,19 @@ func (r *SqlRepo) migrate() error {
slog.Debug("running migration: interface status", "result", r.db.AutoMigrate(&domain.InterfaceStatus{}))
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
existingSysStat := SysStat{}
var existingSysStat SysStat
var err error
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 {
const schemaVersion = 1
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
}
slog.Debug("sys-stat entry written", "schema_version", schemaVersion)
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 1 --> 2
@@ -262,14 +260,10 @@ func (r *SqlRepo) migrate() error {
}
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
}
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 2 --> 3
@@ -307,19 +301,45 @@ func (r *SqlRepo) migrate() error {
if err != nil {
return fmt.Errorf("failed to migrate to multi-auth: %w", err)
}
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
// Migration: 3 --> 4
if existingSysStat.SchemaVersion == 3 {
const schemaVersion = 4
cutoff := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
// Fix zero created_at timestamps for users. Set the to the last known update timestamp.
err := r.db.Model(&domain.User{}).Where("created_at < ?", cutoff).
Update("created_at", gorm.Expr("updated_at")).Error
if err != nil {
slog.Warn("failed to fix zero created_at for users", "error", err)
}
slog.Debug("fixed zero created_at timestamps for users", "schema_version", schemaVersion)
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
}
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
return nil
}
func (r *SqlRepo) addMigration(schemaVersion uint64) (SysStat, error) {
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: schemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return SysStat{}, fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
return sysStat, nil
}
// region interfaces
// GetInterface returns the interface with the given id.

View File

@@ -0,0 +1,168 @@
package adapters
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
return db
}
func TestUpsertUser_SetsCreatedAtWhenZero(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
// CreatedAt is zero
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.False(t, user.CreatedAt.IsZero(), "CreatedAt should be set when it was zero")
assert.Equal(t, ui.UserId(), user.UpdatedBy, "UpdatedBy should be set when it was empty")
assert.WithinDuration(t, user.UpdatedAt, user.CreatedAt, time.Second,
"CreatedAt should be close to UpdatedAt for new user")
}
func TestUpsertUser_PreservesExistingCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ui := domain.SystemAdminContextUserInfo()
originalTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
user := &domain.User{
Identifier: "test-user",
Email: "test@example.com",
BaseModel: domain.BaseModel{
CreatedAt: originalTime,
CreatedBy: "original-creator",
},
}
err := repo.upsertUser(ui, db, user)
require.NoError(t, err)
assert.Equal(t, originalTime, user.CreatedAt, "CreatedAt should not be overwritten")
assert.Equal(t, "original-creator", user.CreatedBy, "CreatedBy should not be overwritten")
}
func TestSaveUser_NewUserGetsCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(&domain.User{}, &domain.UserAuthentication{}, &domain.UserWebauthnCredential{}))
repo := &SqlRepo{db: db, cfg: &config.Config{}}
ctx := domain.SetUserInfo(context.Background(), domain.SystemAdminContextUserInfo())
before := time.Now().Add(-time.Second)
err := repo.SaveUser(ctx, "new-user", func(u *domain.User) (*domain.User, error) {
u.Email = "new@example.com"
return u, nil
})
require.NoError(t, err)
var saved domain.User
require.NoError(t, db.First(&saved, "identifier = ?", "new-user").Error)
assert.False(t, saved.CreatedAt.IsZero(), "CreatedAt should not be zero")
assert.True(t, saved.CreatedAt.After(before), "CreatedAt should be recent")
assert.NotEmpty(t, saved.CreatedBy, "CreatedBy should be set")
}
func TestMigration_FixesZeroCreatedAt(t *testing.T) {
db := newTestDB(t)
// Manually create tables and seed schema version 3
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
// Insert schema versions 1, 2, 3 so migration starts at 3
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
// Insert a user with zero created_at but valid updated_at
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"zero-user", "zero@example.com", time.Time{}, updatedAt,
).Error)
// Run migration
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
// Verify created_at was backfilled from updated_at
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "zero-user").Error)
assert.Equal(t, updatedAt, user.CreatedAt, "created_at should be backfilled from updated_at")
// Verify schema version advanced to 4
var latest SysStat
require.NoError(t, db.Order("schema_version DESC").First(&latest).Error)
assert.Equal(t, uint64(4), latest.SchemaVersion)
}
func TestMigration_DoesNotTouchValidCreatedAt(t *testing.T) {
db := newTestDB(t)
require.NoError(t, db.AutoMigrate(
&SysStat{},
&domain.User{},
&domain.UserAuthentication{},
&domain.Interface{},
&domain.Cidr{},
&domain.Peer{},
&domain.AuditEntry{},
&domain.UserWebauthnCredential{},
))
for v := uint64(1); v <= 3; v++ {
require.NoError(t, db.Create(&SysStat{SchemaVersion: v, MigratedAt: time.Now()}).Error)
}
createdAt := time.Date(2024, 3, 1, 8, 0, 0, 0, time.UTC)
updatedAt := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC)
require.NoError(t, db.Exec(
"INSERT INTO users (identifier, email, created_at, updated_at) VALUES (?, ?, ?, ?)",
"valid-user", "valid@example.com", createdAt, updatedAt,
).Error)
repo := &SqlRepo{db: db, cfg: &config.Config{}}
require.NoError(t, repo.migrate())
var user domain.User
require.NoError(t, db.First(&user, "identifier = ?", "valid-user").Error)
assert.Equal(t, createdAt, user.CreatedAt, "valid created_at should not be modified")
}

View File

@@ -25,7 +25,9 @@ type AuthenticationService interface {
// OauthLoginStep1 initiates the OAuth login flow.
OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error)
// OauthLoginStep2 completes the OAuth login flow and logins the user in.
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, string, error)
// OauthProviderLogoutUrl returns an IdP logout URL for the given provider if supported.
OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool)
}
type WebAuthnService interface {
@@ -331,7 +333,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
}
loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits
user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
user, idTokenHint, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
oauthCode)
cancel()
if err != nil {
@@ -346,7 +348,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
return
}
e.setAuthenticatedUser(r, user)
e.setAuthenticatedUser(r, user, provider, idTokenHint)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
queryParams := returnUrl.Query()
@@ -359,7 +361,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
}
}
func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) {
func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, oauthProvider, idTokenHint string) {
// start a fresh session
e.session.DestroyData(r.Context())
@@ -374,8 +376,9 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) {
currentSession.OauthState = ""
currentSession.OauthNonce = ""
currentSession.OauthProvider = ""
currentSession.OauthProvider = oauthProvider
currentSession.OauthReturnTo = ""
currentSession.OauthIdToken = idTokenHint
e.session.SetData(r.Context(), currentSession)
}
@@ -418,7 +421,7 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
return
}
e.setAuthenticatedUser(r, user)
e.setAuthenticatedUser(r, user, "", "")
respond.JSON(w, http.StatusOK, user)
}
@@ -430,19 +433,33 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
// @Tags Authentication
// @Summary Get all available external login providers.
// @Produce json
// @Success 200 {object} model.Error
// @Success 200 {object} model.LogoutResponse
// @Router /auth/logout [post]
func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context())
if !currentSession.LoggedIn { // Not logged in
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "not logged in"})
respond.JSON(w, http.StatusOK, model.LogoutResponse{Message: "not logged in"})
return
}
postLogoutRedirectUri := e.cfg.Web.ExternalUrl
if e.cfg.Web.BasePath != "" {
postLogoutRedirectUri += e.cfg.Web.BasePath
}
postLogoutRedirectUri += "/#/login"
var redirectUrl *string
if currentSession.OauthProvider != "" {
if idpLogoutUrl, ok := e.authService.OauthProviderLogoutUrl(currentSession.OauthProvider,
currentSession.OauthIdToken, postLogoutRedirectUri); ok {
redirectUrl = &idpLogoutUrl
}
}
e.session.DestroyData(r.Context())
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "logout ok"})
respond.JSON(w, http.StatusOK, model.LogoutResponse{Message: "logout ok", RedirectUrl: redirectUrl})
}
}
@@ -693,7 +710,7 @@ func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc {
return
}
e.setAuthenticatedUser(r, user)
e.setAuthenticatedUser(r, user, "", "")
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
}

View File

@@ -30,6 +30,7 @@ type SessionData struct {
OauthNonce string
OauthProvider string
OauthReturnTo string
OauthIdToken string
WebAuthnData string
@@ -89,5 +90,6 @@ func (s *SessionWrapper) defaultSessionData() SessionData {
OauthNonce: "",
OauthProvider: "",
OauthReturnTo: "",
OauthIdToken: "",
}
}

View File

@@ -45,6 +45,11 @@ type OauthInitiationResponse struct {
State string
}
type LogoutResponse struct {
Message string `json:"Message"`
RedirectUrl *string `json:"RedirectUrl,omitempty"`
}
type WebAuthnCredentialRequest struct {
Name string `json:"Name"`
}

View File

@@ -65,6 +65,11 @@ type AuthenticatorOauth interface {
RegistrationEnabled() bool
// GetAllowedDomains returns the list of whitelisted domains
GetAllowedDomains() []string
// GetAllowedUserGroups returns the list of whitelisted user groups.
// If non-empty, at least one user group must match.
GetAllowedUserGroups() []string
// GetLogoutUrl returns an IdP logout URL if supported by the provider.
GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool)
}
// AuthenticatorLdap is the interface for all LDAP authenticators.
@@ -497,31 +502,63 @@ func isDomainAllowed(email string, allowedDomains []string) bool {
return false
}
func isAnyAllowedUserGroup(userGroups, allowedUserGroups []string) bool {
if len(allowedUserGroups) == 0 {
return true
}
allowed := make(map[string]struct{}, len(allowedUserGroups))
for _, group := range allowedUserGroups {
trimmed := strings.TrimSpace(group)
if trimmed == "" {
continue
}
allowed[trimmed] = struct{}{}
}
if len(allowed) == 0 {
return false
}
for _, group := range userGroups {
if _, ok := allowed[strings.TrimSpace(group)]; ok {
return true
}
}
return false
}
// OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and
// fetching the user information.
func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) {
func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, string, error) {
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return nil, fmt.Errorf("missing oauth provider %s", providerId)
return nil, "", fmt.Errorf("missing oauth provider %s", providerId)
}
oauth2Token, err := oauthProvider.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("unable to exchange code: %w", err)
return nil, "", fmt.Errorf("unable to exchange code: %w", err)
}
idTokenHint, _ := oauth2Token.Extra("id_token").(string)
rawUserInfo, err := oauthProvider.GetUserInfo(ctx, oauth2Token, nonce)
if err != nil {
return nil, fmt.Errorf("unable to fetch user information: %w", err)
return nil, "", fmt.Errorf("unable to fetch user information: %w", err)
}
userInfo, err := oauthProvider.ParseUserInfo(rawUserInfo)
if err != nil {
return nil, fmt.Errorf("unable to parse user information: %w", err)
return nil, "", fmt.Errorf("unable to parse user information: %w", err)
}
if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) {
return nil, fmt.Errorf("user %s is not in allowed domains", userInfo.Email)
return nil, "", fmt.Errorf("user %s is not in allowed domains", userInfo.Email)
}
if !isAnyAllowedUserGroup(userInfo.UserGroups, oauthProvider.GetAllowedUserGroups()) {
return nil, "", fmt.Errorf("user %s is not in allowed user groups", userInfo.Identifier)
}
ctx = domain.SetUserInfo(ctx,
@@ -537,7 +574,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
Error: err.Error(),
},
})
return nil, fmt.Errorf("unable to process user information: %w", err)
return nil, "", fmt.Errorf("unable to process user information: %w", err)
}
if user.IsLocked() || user.IsDisabled() {
@@ -549,7 +586,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
Error: "user is locked",
},
})
return nil, errors.New("user is locked")
return nil, "", errors.New("user is locked")
}
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
@@ -561,7 +598,16 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
},
})
return user, nil
return user, idTokenHint, nil
}
func (a *Authenticator) OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool) {
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return "", false
}
return oauthProvider.GetLogoutUrl(idTokenHint, postLogoutRedirectUri)
}
func (a *Authenticator) processUserInfo(

View File

@@ -29,6 +29,7 @@ type PlainOauthAuthenticator struct {
userInfoLogging bool
sensitiveInfoLogging bool
allowedDomains []string
allowedUserGroups []string
}
func newPlainOauthAuthenticator(
@@ -60,6 +61,7 @@ func newPlainOauthAuthenticator(
provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
return provider, nil
}
@@ -73,6 +75,14 @@ func (p PlainOauthAuthenticator) GetAllowedDomains() []string {
return p.allowedDomains
}
func (p PlainOauthAuthenticator) GetAllowedUserGroups() []string {
return p.allowedUserGroups
}
func (p PlainOauthAuthenticator) GetLogoutUrl(_, _ string) (string, bool) {
return "", false
}
// RegistrationEnabled returns whether registration is enabled for the OAuth authenticator.
func (p PlainOauthAuthenticator) RegistrationEnabled() bool {
return p.registrationEnabled

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"net/url"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
@@ -26,6 +27,9 @@ type OidcAuthenticator struct {
userInfoLogging bool
sensitiveInfoLogging bool
allowedDomains []string
allowedUserGroups []string
endSessionEndpoint string
logoutIdpSession bool
}
func newOidcAuthenticator(
@@ -61,6 +65,17 @@ func newOidcAuthenticator(
provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
provider.logoutIdpSession = cfg.LogoutIdpSession == nil || *cfg.LogoutIdpSession
var providerMetadata struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err = provider.provider.Claims(&providerMetadata); err != nil {
slog.Debug("OIDC: failed to parse provider metadata", "provider", cfg.ProviderName, "error", err)
} else {
provider.endSessionEndpoint = providerMetadata.EndSessionEndpoint
}
return provider, nil
}
@@ -74,6 +89,38 @@ func (o OidcAuthenticator) GetAllowedDomains() []string {
return o.allowedDomains
}
func (o OidcAuthenticator) GetAllowedUserGroups() []string {
return o.allowedUserGroups
}
func (o OidcAuthenticator) GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool) {
if !o.logoutIdpSession {
return "", false
}
if o.endSessionEndpoint == "" {
slog.Debug("OIDC logout URL generation disabled: provider has no end_session_endpoint", "provider", o.name)
return "", false
}
logoutUrl, err := url.Parse(o.endSessionEndpoint)
if err != nil {
slog.Debug("OIDC logout URL generation failed, unable to parse end_session_endpoint url",
"provider", o.name, "error", err)
return "", false
}
params := logoutUrl.Query()
if idTokenHint != "" {
params.Set("id_token_hint", idTokenHint)
}
if postLogoutRedirectUri != "" {
params.Set("post_logout_redirect_uri", postLogoutRedirectUri)
}
logoutUrl.RawQuery = params.Encode()
return logoutUrl.String(), true
}
// RegistrationEnabled returns whether registration is enabled for this authenticator.
func (o OidcAuthenticator) RegistrationEnabled() bool {
return o.registrationEnabled

View File

@@ -16,6 +16,7 @@ func parseOauthUserInfo(
) (*domain.AuthenticatorUserInfo, error) {
var isAdmin bool
var adminInfoAvailable bool
userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil)
// first try to match the is_admin field against the given regex
if mapping.IsAdmin != "" {
@@ -29,7 +30,6 @@ func parseOauthUserInfo(
// next try to parse the user's groups
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
adminInfoAvailable = true
userGroups := internal.MapDefaultStringSlice(raw, mapping.UserGroups, nil)
re := adminMapping.GetAdminGroupRegex()
for _, group := range userGroups {
if re.MatchString(strings.TrimSpace(group)) {
@@ -42,6 +42,7 @@ func parseOauthUserInfo(
userInfo := &domain.AuthenticatorUserInfo{
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, mapping.UserIdentifier, "")),
Email: internal.MapDefaultString(raw, mapping.Email, ""),
UserGroups: userGroups,
Firstname: internal.MapDefaultString(raw, mapping.Firstname, ""),
Lastname: internal.MapDefaultString(raw, mapping.Lastname, ""),
Phone: internal.MapDefaultString(raw, mapping.Phone, ""),

View File

@@ -96,6 +96,7 @@ func Test_parseOauthUserInfo_admin_group(t *testing.T) {
assert.Equal(t, info.Firstname, "Test User")
assert.Equal(t, info.Lastname, "")
assert.Equal(t, info.Email, "test@mydomain.net")
assert.Equal(t, info.UserGroups, []string{"abuse@mydomain.net", "postmaster@mydomain.net", "wgportal-admins@mydomain.net"})
}
func Test_parseOauthUserInfo_admin_value(t *testing.T) {

View File

@@ -533,6 +533,7 @@ func (m Manager) create(ctx context.Context, user *domain.User) (*domain.User, e
}
err = m.users.SaveUser(ctx, user.Identifier, func(u *domain.User) (*domain.User, error) {
user.CopyCalculatedAttributes(u, false)
return user, nil
})
if err != nil {

View File

@@ -258,6 +258,10 @@ type OpenIDConnectProvider struct {
// AllowedDomains defines the list of allowed domains
AllowedDomains []string `yaml:"allowed_domains"`
// AllowedUserGroups defines the list of allowed user groups.
// If not empty, at least one group from the user's group claim must match.
AllowedUserGroups []string `yaml:"allowed_user_groups"`
// FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields
FieldMap OauthFields `yaml:"field_map"`
@@ -274,6 +278,11 @@ type OpenIDConnectProvider struct {
// 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"`
// LogoutIdpSession controls whether the user's session at the OIDC provider is terminated on logout.
// If set to true (default), the user will be redirected to the IdP's end_session_endpoint after local logout.
// If set to false, only the local wg-portal session is invalidated.
LogoutIdpSession *bool `yaml:"logout_idp_session"`
}
// OAuthProvider contains the configuration for the OAuth provider.
@@ -303,6 +312,10 @@ type OAuthProvider struct {
// AllowedDomains defines the list of allowed domains
AllowedDomains []string `yaml:"allowed_domains"`
// AllowedUserGroups defines the list of allowed user groups.
// If not empty, at least one group from the user's group claim must match.
AllowedUserGroups []string `yaml:"allowed_user_groups"`
// FieldMap is used to map the names of the user-info endpoint fields to wg-portal fields
FieldMap OauthFields `yaml:"field_map"`

View File

@@ -12,6 +12,7 @@ type LoginProviderInfo struct {
type AuthenticatorUserInfo struct {
Identifier UserIdentifier
Email string
UserGroups []string
Firstname string
Lastname string
Phone string

View File

@@ -68,7 +68,7 @@ type User struct {
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"`
ApiToken string `form:"api_token" binding:"omitempty" gorm:"serializer:encstr"`
ApiTokenCreated *time.Time
LinkedPeerCount int `gorm:"-"`