Compare commits

...

6 Commits

Author SHA1 Message Date
dependabot[bot]
fa74f17f06 chore(deps): bump the actions group across 1 directory with 5 updates
Bumps the actions group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [nolar/setup-k3d-k3s](https://github.com/nolar/setup-k3d-k3s) | `1.0.10` | `1.1.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.0.0` | `4.1.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.0.0` | `7.1.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.0` | `7.0.1` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `2.6.1` | `3.0.0` |



Updates `nolar/setup-k3d-k3s` from 1.0.10 to 1.1.0
- [Release notes](https://github.com/nolar/setup-k3d-k3s/releases)
- [Commits](8bf8d22160...62c9d1bd2b)

Updates `docker/login-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](b45d80f862...4907a6ddec)

Updates `docker/build-push-action` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](d08e5c354a...bcafcacb16)

Updates `actions/upload-artifact` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](bbbca2ddaa...043fb46d1a)

Updates `softprops/action-gh-release` from 2.6.1 to 3.0.0
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](153bb8e044...b430933298)

---
updated-dependencies:
- dependency-name: nolar/setup-k3d-k3s
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 15:04:28 +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
29 changed files with 471 additions and 194 deletions

View File

@@ -44,7 +44,7 @@ jobs:
- name: Run chart-testing (lint)
run: ct lint --config ct.yaml
- uses: nolar/setup-k3d-k3s@8bf8d22160e8b1d184dcb780e390d6952a7eec65 # v1.0.10
- uses: nolar/setup-k3d-k3s@62c9d1bd2bc843275c85d2e7dcd696edc1160eee # v1.1.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -32,14 +32,14 @@ jobs:
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -68,7 +68,7 @@ jobs:
type=semver,pattern=v{{major}}
- name: Build and push Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -80,7 +80,7 @@ jobs:
BUILD_VERSION=${{ env.BUILD_VERSION }}
- name: Export binaries from images
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
@@ -96,7 +96,7 @@ jobs:
done
- name: Upload binaries
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binaries
path: binaries/wg-portal_linux*
@@ -115,7 +115,7 @@ jobs:
name: binaries
- name: Create GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
files: 'wg-portal_linux*'
generate_release_notes: true

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

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

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

8
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.3
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/prometheus-community/pro-bing v0.8.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
@@ -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

16
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,16 +97,16 @@ 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/webauthn v0.16.3 h1:RorP0c6VbaKP0i0Jxf/vAf7EFb2lmdLW8GLKITeaN5A=
github.com/go-webauthn/webauthn v0.16.3/go.mod h1:R2xjJxSPat5PYKg5r6cUmqXgbHtbv4GmF6uGkqFMLNI=
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/golang-jwt/jwt/v5 v5.0.0/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=

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

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