Compare commits

..

1 Commits

Author SHA1 Message Date
Christoph Haas
473915c021 chore: update frontend deps 2026-03-31 23:21:33 +02:00
31 changed files with 208 additions and 674 deletions

View File

@@ -1,8 +1,7 @@
# 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
@@ -52,11 +51,6 @@ 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,7 +47,6 @@ 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
@@ -58,7 +57,6 @@ 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,9 +144,6 @@ 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
@@ -204,9 +201,6 @@ auth:
- email
- profile
- i-want-some-groups
allowed_user_groups:
- admin-group-name
- vpn-users
field_map:
email: email
firstname: name

View File

@@ -561,10 +561,6 @@ 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.
@@ -600,10 +596,6 @@ 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
@@ -647,10 +639,6 @@ 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,40 +66,6 @@ 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

@@ -1,121 +0,0 @@
<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,6 +11,7 @@ export const auditStore = defineStore('audit', {
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
@@ -40,22 +41,33 @@ 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() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize
}
this.calculatePages()
},
previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
}
this.calculatePages()
},
setEntries(entries) {
this.entries = entries
this.calculatePages()
this.fetching = false
},
async LoadEntries() {

View File

@@ -108,19 +108,12 @@ export const authStore = defineStore('auth',{
this.setUserInfo(null)
this.ResetReturnUrl() // just to be sure^^
let logoutResponse = null
try {
logoutResponse = await apiWrapper.post(`/auth/logout`)
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,6 +19,7 @@ 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
@@ -86,22 +87,33 @@ 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() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize
}
this.calculatePages()
},
previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
}
this.calculatePages()
},
setPeers(peers) {
this.peers = peers
this.calculatePages()
this.fetching = false
this.trafficStats = {}
},

View File

@@ -20,6 +20,7 @@ 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
@@ -79,19 +80,29 @@ 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() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize
}
this.calculatePages()
},
previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
}
this.calculatePages()
},
setPeers(peers) {
this.peers = peers

View File

@@ -12,6 +12,7 @@ export const userStore = defineStore('users', {
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
@@ -42,22 +43,33 @@ 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() {
if (this.hasNextPage) {
this.pageOffset += this.pageSize
}
this.calculatePages()
},
previousPage() {
if (this.hasPrevPage) {
this.pageOffset -= this.pageSize
}
this.calculatePages()
},
setUsers(users) {
this.users = users
this.calculatePages()
this.fetching = false
},
setUserPeers(peers) {

View File

@@ -1,7 +1,6 @@
<script setup>
import { onMounted } from "vue";
import {auditStore} from "@/stores/audit";
import Pagination from "@/components/Pagination.vue";
const audit = auditStore()
@@ -61,24 +60,28 @@ onMounted(async () => {
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<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 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>
<div class="col-12 col-md-6">
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @change="audit.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
@@ -89,4 +92,5 @@ onMounted(async () => {
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,10 +1,9 @@
<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 Pagination from "@/components/Pagination.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 {computed, onMounted, ref} from "vue";
import {peerStore} from "@/stores/peers";
@@ -483,23 +482,26 @@ onMounted(async () => {
<hr v-if="interfaces.Count!==0">
<div v-if="interfaces.Count!==0" class="mt-3">
<div class="row">
<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 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>
<div class="col-12 col-md-6">
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @change="peers.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="peers.pageSize" class="form-select" @click="peers.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>

View File

@@ -6,7 +6,6 @@ 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";
@@ -67,6 +66,7 @@ onMounted(async () => {
await profile.LoadPeers()
await profile.LoadStats()
await profile.LoadInterfaces()
await profile.calculatePages(); // Forces to show initial page number
})
</script>
@@ -185,25 +185,28 @@ onMounted(async () => {
<hr>
<div class="mt-3">
<div class="row">
<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 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>
<div class="col-12 col-md-6">
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">
{{ $t('general.pagination.size')}}:
</label>
<div class="col-sm-6">
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @change="profile.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="profile.pageSize" class="form-select" @click="profile.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>

View File

@@ -1,9 +1,8 @@
<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 Pagination from "@/components/Pagination.vue";
import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue";
import {useI18n} from "vue-i18n";
const users = userStore()
@@ -166,24 +165,28 @@ onMounted(() => {
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<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 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>
<div class="col-12 col-md-6">
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-md-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @change="users.afterPageSizeChange()">
<select id="paginationSelector" v-model.number="users.pageSize" class="form-select" @click="users.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
@@ -194,4 +197,5 @@ 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.2
github.com/go-webauthn/webauthn v0.16.3
github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.16.1
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.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // 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,14 +81,12 @@ 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.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
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/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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
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-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.3 h1:RorP0c6VbaKP0i0Jxf/vAf7EFb2lmdLW8GLKITeaN5A=
github.com/go-webauthn/webauthn v0.16.3/go.mod h1:R2xjJxSPat5PYKg5r6cUmqXgbHtbv4GmF6uGkqFMLNI=
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/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -195,8 +195,6 @@ 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=
@@ -236,8 +234,6 @@ 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

@@ -232,19 +232,21 @@ 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{}))
var existingSysStat SysStat
var err error
existingSysStat := SysStat{}
r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 {
const schemaVersion = 1
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
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)
}
slog.Debug("sys-stat entry written", "schema_version", schemaVersion)
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 1 --> 2
@@ -260,10 +262,14 @@ func (r *SqlRepo) migrate() error {
}
slog.Debug("migrated interface create_default_peer flags", "schema_version", schemaVersion)
}
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return err
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 = sysStat // ensure that follow-up checks test against the latest version
}
// Migration: 2 --> 3
@@ -301,43 +307,17 @@ func (r *SqlRepo) migrate() error {
if err != nil {
return fmt.Errorf("failed to migrate to multi-auth: %w", err)
}
existingSysStat, err = r.addMigration(schemaVersion) // ensure that follow-up checks test against the latest version
if err != nil {
return 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
}
}
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 fmt.Errorf("failed to write sysstat entry for schema version %d: %w", schemaVersion, err)
}
return sysStat, nil
existingSysStat = sysStat // ensure that follow-up checks test against the latest version
}
return nil
}
// region interfaces

View File

@@ -1,168 +0,0 @@
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,9 +25,7 @@ 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, string, error)
// OauthProviderLogoutUrl returns an IdP logout URL for the given provider if supported.
OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool)
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
}
type WebAuthnService interface {
@@ -333,7 +331,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
}
loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits
user, idTokenHint, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
oauthCode)
cancel()
if err != nil {
@@ -348,7 +346,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
return
}
e.setAuthenticatedUser(r, user, provider, idTokenHint)
e.setAuthenticatedUser(r, user)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
queryParams := returnUrl.Query()
@@ -361,7 +359,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
}
}
func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, oauthProvider, idTokenHint string) {
func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) {
// start a fresh session
e.session.DestroyData(r.Context())
@@ -376,9 +374,8 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, o
currentSession.OauthState = ""
currentSession.OauthNonce = ""
currentSession.OauthProvider = oauthProvider
currentSession.OauthProvider = ""
currentSession.OauthReturnTo = ""
currentSession.OauthIdToken = idTokenHint
e.session.SetData(r.Context(), currentSession)
}
@@ -421,7 +418,7 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
return
}
e.setAuthenticatedUser(r, user, "", "")
e.setAuthenticatedUser(r, user)
respond.JSON(w, http.StatusOK, user)
}
@@ -433,33 +430,19 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
// @Tags Authentication
// @Summary Get all available external login providers.
// @Produce json
// @Success 200 {object} model.LogoutResponse
// @Success 200 {object} model.Error
// @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.LogoutResponse{Message: "not logged in"})
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, 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.LogoutResponse{Message: "logout ok", RedirectUrl: redirectUrl})
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "logout ok"})
}
}
@@ -710,7 +693,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,7 +30,6 @@ type SessionData struct {
OauthNonce string
OauthProvider string
OauthReturnTo string
OauthIdToken string
WebAuthnData string
@@ -90,6 +89,5 @@ func (s *SessionWrapper) defaultSessionData() SessionData {
OauthNonce: "",
OauthProvider: "",
OauthReturnTo: "",
OauthIdToken: "",
}
}

View File

@@ -45,11 +45,6 @@ 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,11 +65,6 @@ 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.
@@ -502,63 +497,31 @@ 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, string, error) {
func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, 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)
}
if !isAnyAllowedUserGroup(userInfo.UserGroups, oauthProvider.GetAllowedUserGroups()) {
return nil, "", fmt.Errorf("user %s is not in allowed user groups", userInfo.Identifier)
return nil, fmt.Errorf("user %s is not in allowed domains", userInfo.Email)
}
ctx = domain.SetUserInfo(ctx,
@@ -574,7 +537,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() {
@@ -586,7 +549,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)
@@ -598,16 +561,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
},
})
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)
return user, nil
}
func (a *Authenticator) processUserInfo(

View File

@@ -29,7 +29,6 @@ type PlainOauthAuthenticator struct {
userInfoLogging bool
sensitiveInfoLogging bool
allowedDomains []string
allowedUserGroups []string
}
func newPlainOauthAuthenticator(
@@ -61,7 +60,6 @@ func newPlainOauthAuthenticator(
provider.userInfoLogging = cfg.LogUserInfo
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
return provider, nil
}
@@ -75,14 +73,6 @@ 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,7 +6,6 @@ import (
"errors"
"fmt"
"log/slog"
"net/url"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
@@ -27,9 +26,6 @@ type OidcAuthenticator struct {
userInfoLogging bool
sensitiveInfoLogging bool
allowedDomains []string
allowedUserGroups []string
endSessionEndpoint string
logoutIdpSession bool
}
func newOidcAuthenticator(
@@ -65,17 +61,6 @@ 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
}
@@ -89,38 +74,6 @@ 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,7 +16,6 @@ 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 != "" {
@@ -30,6 +29,7 @@ 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,7 +42,6 @@ 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,7 +96,6 @@ 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,7 +533,6 @@ 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,10 +258,6 @@ 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"`
@@ -278,11 +274,6 @@ 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.
@@ -312,10 +303,6 @@ 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,7 +12,6 @@ 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" gorm:"serializer:encstr"`
ApiToken string `form:"api_token" binding:"omitempty"`
ApiTokenCreated *time.Time
LinkedPeerCount int `gorm:"-"`