feat: add link-based configuration emails (#725) (#754)

* feat: add link-based configuration emails (#725)

* fix tests
This commit is contained in:
h44z
2026-09-10 22:04:20 +02:00
committed by GitHub
parent ebeaadd7de
commit 32ef6048fb
23 changed files with 550 additions and 114 deletions
+17 -3
View File
@@ -1,5 +1,6 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router';
import router, { publicPages } from '@/router';
import {computed, getCurrentInstance, nextTick, onMounted, ref} from "vue";
import { authStore } from "./stores/auth";
import { securityStore } from "./stores/security";
@@ -24,14 +25,27 @@ onMounted(async () => {
let wasLoggedIn = auth.IsAuthenticated;
try {
await auth.LoadSession();
await auth.EnsureSession();
await settings.LoadSettings(); // only logs errors, does not throw
console.log("WireGuard Portal session is valid");
// redirect to originally stored return-url (if set)
if (!wasLoggedIn && router.currentRoute.value.path === '/login') {
const returnUrl = auth.ReturnUrl;
if (returnUrl && returnUrl !== '/login') {
auth.ResetReturnUrl();
router.push(returnUrl);
}
}
} catch (e) {
if (wasLoggedIn) {
console.log("WireGuard Portal invalid - logging out");
await auth.Logout();
console.log("WireGuard Portal session invalid");
const currentRoute = router.currentRoute.value;
if (currentRoute && !publicPages.includes(currentRoute.path)) {
auth.SetReturnUrl(currentRoute.fullPath);
router.push('/login');
}
}
}
+7
View File
@@ -4,4 +4,11 @@ export function base64_url_encode(input) {
output = output.replaceAll('/', '_')
output = output.replaceAll('=', '-')
return output
}
export function base64_url_decode(input) {
let output = input.replaceAll('-', '=')
output = output.replaceAll('_', '/')
output = output.replaceAll('.', '+')
return atob(output)
}
+3 -3
View File
@@ -81,9 +81,9 @@ function handleResponse(response) {
if (!response.ok) {
const auth = authStore();
if ([401, 403].includes(response.status) && auth.IsAuthenticated) {
console.log("automatic logout initiated...");
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
auth.Logout();
console.log("unauthorized response received, redirecting to login...");
// handle unauthorized session without clearing return url
auth.HandleUnauthorized();
}
const error = (data && data.Message) || response.statusText;
+11
View File
@@ -32,6 +32,17 @@
"button": "Anmelden",
"button-webauthn": "Passkey verwenden"
},
"peer-config-download": {
"headline": "VPN-Konfiguration wird heruntergeladen",
"in-progress": "Der Download Ihrer WireGuard-Konfiguration startet automatisch. Bitte warten...",
"success-title": "Konfiguration heruntergeladen",
"success-message": "Ihre WireGuard-Konfiguration wurde heruntergeladen.",
"error-title": "Download fehlgeschlagen",
"error-message": "Die WireGuard-Konfiguration konnte nicht heruntergeladen werden.",
"manual-hint": "Falls der Download nicht automatisch gestartet wurde, verwenden Sie die Schaltfläche unten.",
"button-retry": "Konfiguration herunterladen",
"button-home": "Zur Startseite"
},
"menu": {
"home": "Home",
"interfaces": "Schnittstellen",
+11
View File
@@ -32,6 +32,17 @@
"button": "Sign in",
"button-webauthn": "Use Passkey"
},
"peer-config-download": {
"headline": "Downloading VPN Configuration",
"in-progress": "Your WireGuard configuration download will start automatically. Please wait...",
"success-title": "Configuration downloaded",
"success-message": "Your WireGuard configuration has been downloaded.",
"error-title": "Download failed",
"error-message": "Failed to download the WireGuard configuration.",
"manual-hint": "If the download did not start automatically, use the button below.",
"button-retry": "Download configuration",
"button-home": "Go to start page"
},
"menu": {
"home": "Home",
"interfaces": "Interfaces",
+42 -8
View File
@@ -6,6 +6,8 @@ import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security'
import {notify} from "@kyvg/vue3-notification";
export const publicPages = ['/', '/login', '/key-generator', '/ip-calculator']
const router = createRouter({
// No base argument: createWebHashHistory() defaults to location.pathname + location.search,
// which is correct for /app/, {web.base_path}/app/ and the dev server at /.
@@ -45,6 +47,14 @@ const router = createRouter({
// which is lazy-loaded when the route is visited.
component: () => import('../views/ProfileView.vue')
},
{
path: '/peer/config/:id',
name: 'peer-config-download',
// This is a "deep link" target used by link-only configuration emails. As it is not part of the
// public pages, unauthenticated users are redirected to the login page first and are returned here
// (starting the download) only after a successful authentication.
component: () => import('../views/PeerConfigDownloadView.vue')
},
{
path: '/settings',
name: 'settings',
@@ -86,12 +96,14 @@ router.beforeEach(async (to) => {
const auth = authStore()
// check if the request was a successful oauth login
if ('wgLoginState' in to.query && !auth.IsAuthenticated) {
const state = to.query['wgLoginState']
const returnUrl = auth.ReturnUrl
console.log("Oauth login callback:", state)
const searchParams = new URLSearchParams(window.location.search)
const oauthState = to.query['wgLoginState'] || searchParams.get('wgLoginState')
if (state === "success") {
if (oauthState && !auth.IsAuthenticated) {
const returnUrl = auth.ReturnUrl
console.log("Oauth login callback:", oauthState)
if (oauthState === "success") {
try {
const uid = await auth.LoadSession()
console.log("Oauth login completed for UID:", uid)
@@ -99,12 +111,16 @@ router.beforeEach(async (to) => {
notify({
title: "Logged in",
text: "Authentication suceeded!",
text: "Authentication succeeded!",
type: 'success',
})
auth.ResetReturnUrl()
return returnUrl
if (searchParams.has('wgLoginState')) {
const cleanUrl = window.location.pathname + window.location.hash
window.history.replaceState(null, '', cleanUrl)
}
return returnUrl || '/'
} catch (e) {
notify({
title: "Login failed!",
@@ -125,8 +141,26 @@ router.beforeEach(async (to) => {
}
}
// ensure session validity is verified with backend before checking route access
if (!auth.sessionChecked) {
try {
await auth.EnsureSession()
} catch (e) {
// session is not authenticated
}
}
// redirect to returnUrl if already authenticated and accessing login page
if (to.path === '/login' && auth.IsAuthenticated) {
const returnUrl = auth.ReturnUrl
if (returnUrl && returnUrl !== '/login') {
auth.ResetReturnUrl()
return returnUrl
}
return '/'
}
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/', '/login', '/key-generator', '/ip-calculator']
const authRequired = !publicPages.includes(to.path)
if (authRequired && !auth.IsAuthenticated) {
+58 -16
View File
@@ -3,7 +3,7 @@ import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
import { websocketWrapper } from '@/helpers/websocket-wrapper'
import router from '../router'
import router, { publicPages } from '../router'
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
import {base64_url_encode} from "@/helpers/encoding";
@@ -15,6 +15,8 @@ export const authStore = defineStore('auth',{
returnUrl: localStorage.getItem('returnUrl'),
webAuthnCredentials: [],
fetching: false,
sessionChecked: false,
sessionPromise: null,
}),
getters: {
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
@@ -55,12 +57,29 @@ export const authStore = defineStore('auth',{
})
},
// EnsureSession returns a promise that resolves if session is already checked or starts loading it once.
async EnsureSession() {
if (this.sessionChecked) {
if (this.user) {
return this.user.Identifier
}
return Promise.reject(new Error('session not authenticated'))
}
if (this.sessionPromise) {
return this.sessionPromise
}
this.sessionPromise = this.LoadSession().finally(() => {
this.sessionPromise = null
})
return this.sessionPromise
},
// LoadSession returns promise that might have been rejected if the session was not authenticated.
async LoadSession() {
return apiWrapper.get(`/auth/session`)
.then(session => {
this.sessionChecked = true
if (session.LoggedIn === true) {
this.ResetReturnUrl()
this.setUserInfo(session)
return session.UserIdentifier
} else {
@@ -69,6 +88,7 @@ export const authStore = defineStore('auth',{
}
})
.catch(err => {
this.sessionChecked = true
this.setUserInfo(null)
return Promise.reject(err)
})
@@ -94,7 +114,10 @@ export const authStore = defineStore('auth',{
async Login(username, password) {
return apiWrapper.post(`/auth/login`, { username, password })
.then(user => {
this.ResetReturnUrl()
if (!user || !user.Identifier) {
this.setUserInfo(null)
return Promise.reject(new Error("login failed"))
}
this.setUserInfo(user)
return user.Identifier
})
@@ -104,9 +127,19 @@ export const authStore = defineStore('auth',{
return Promise.reject(new Error("login failed"))
})
},
HandleUnauthorized() {
this.setUserInfo(null)
this.sessionChecked = true
const currentRoute = router.currentRoute.value
if (currentRoute && !publicPages.includes(currentRoute.path)) {
this.SetReturnUrl(currentRoute.fullPath)
}
router.push('/login')
},
async Logout() {
this.setUserInfo(null)
this.ResetReturnUrl() // just to be sure^^
this.sessionChecked = true
let logoutResponse = null
try {
@@ -259,8 +292,11 @@ export const authStore = defineStore('auth',{
console.log("Finishing WebAuthn login ...")
return apiWrapper.post(`/auth/webauthn/login/finish`, asseResp)
.then(user => {
if (!user || !user.Identifier) {
this.setUserInfo(null)
return Promise.reject(new Error("login failed"))
}
console.log("Passkey login finished successfully for user:", user.Identifier)
this.ResetReturnUrl()
this.setUserInfo(user)
return user.Identifier
})
@@ -284,28 +320,34 @@ export const authStore = defineStore('auth',{
// -- internal setters
setUserInfo(userInfo) {
// store user details and jwt in local storage to keep user logged in between page refreshes
if (userInfo) {
if ('UserIdentifier' in userInfo) { // session object
if (userInfo && (userInfo.Identifier || userInfo.UserIdentifier)) {
if ('UserIdentifier' in userInfo && userInfo.UserIdentifier) { // session object
this.user = {
Identifier: userInfo['UserIdentifier'],
Firstname: userInfo['UserFirstname'],
Lastname: userInfo['UserLastname'],
Email: userInfo['UserEmail'],
IsAdmin: userInfo['IsAdmin']
Firstname: userInfo['UserFirstname'] || '',
Lastname: userInfo['UserLastname'] || '',
Email: userInfo['UserEmail'] || '',
IsAdmin: userInfo['IsAdmin'] || false
}
} else { // user object
} else if ('Identifier' in userInfo && userInfo.Identifier) { // user object
this.user = {
Identifier: userInfo['Identifier'],
Firstname: userInfo['Firstname'],
Lastname: userInfo['Lastname'],
Email: userInfo['Email'],
IsAdmin: userInfo['IsAdmin']
Firstname: userInfo['Firstname'] || '',
Lastname: userInfo['Lastname'] || '',
Email: userInfo['Email'] || '',
IsAdmin: userInfo['IsAdmin'] || false
}
} else {
this.user = null
}
} else {
this.user = null
}
if (this.user) {
localStorage.setItem('user', JSON.stringify(this.user))
websocketWrapper.connect()
} else {
this.user = null
localStorage.removeItem('user')
websocketWrapper.disconnect()
}
+20 -2
View File
@@ -12,6 +12,10 @@ const settings = settingsStore()
const loggingIn = ref(false)
const username = ref("")
const password = ref("")
// Capture the return URL as soon as the login page is opened. The auth store resets the stored return URL
// during a successful login, so we need to remember the original destination (for example a deep link from a
// configuration email) before starting the authentication.
const returnUrl = ref(auth.ReturnUrl || '/')
const usernameInvalid = computed(() => username.value === "")
const passwordInvalid = computed(() => password.value === "")
@@ -22,12 +26,20 @@ const showLoginForm = computed(() => {
});
onMounted(async () => {
returnUrl.value = auth.ReturnUrl || '/'
if (auth.IsAuthenticated) {
const dest = returnUrl.value !== '/login' ? returnUrl.value : '/'
auth.ResetReturnUrl()
router.push(dest)
return
}
await settings.LoadSettings()
})
const login = async function () {
console.log("Performing login for user:", username.value);
loggingIn.value = true;
const dest = returnUrl.value && returnUrl.value !== '/login' ? returnUrl.value : (auth.ReturnUrl || '/')
auth.Login(username.value, password.value)
.then(uid => {
notify({
@@ -37,7 +49,8 @@ const login = async function () {
});
loggingIn.value = false;
settings.LoadSettings(); // reload full settings
router.push(auth.ReturnUrl);
auth.ResetReturnUrl();
router.push(dest);
})
.catch(error => {
notify({
@@ -55,6 +68,7 @@ const login = async function () {
const loginWebAuthn = async function () {
console.log("Performing webauthn login");
loggingIn.value = true;
const dest = returnUrl.value && returnUrl.value !== '/login' ? returnUrl.value : (auth.ReturnUrl || '/')
auth.LoginWebAuthn()
.then(uid => {
notify({
@@ -64,7 +78,8 @@ const loginWebAuthn = async function () {
});
loggingIn.value = false;
settings.LoadSettings(); // reload full settings
router.push(auth.ReturnUrl);
auth.ResetReturnUrl();
router.push(dest);
})
.catch(error => {
notify({
@@ -83,6 +98,9 @@ const externalLogin = function (provider) {
console.log("Performing external login for provider", provider.Identifier);
loggingIn.value = true;
console.log(router.currentRoute.value);
if (returnUrl.value && returnUrl.value !== '/login') {
auth.SetReturnUrl(returnUrl.value);
}
// Derive the return URL from the live document location, never from the build-time asset base
// (import.meta.env.BASE_URL): the app is mounted at {web.base_path}/app/ in production and at /
// under `npm run dev`, so window.location is the only reliable source.
@@ -0,0 +1,112 @@
<script setup>
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { RouterLink } from "vue-router";
import { useI18n } from "vue-i18n";
import { notify } from "@kyvg/vue3-notification";
import { authStore } from "@/stores/auth";
import { peerStore } from "@/stores/peers";
import { base64_url_decode } from "@/helpers/encoding";
const { t } = useI18n()
const route = useRoute()
const peers = peerStore()
const inProgress = ref(true)
const failed = ref(false)
let peerId = ""
let configStyle = "wgquick"
function triggerBrowserDownload(filename, text) {
// credit: https://www.bitdegree.org/learn/javascript-download
let element = document.createElement('a')
element.setAttribute('href', 'data:application/octet-stream;charset=utf-8,' + encodeURIComponent(text))
element.setAttribute('download', filename)
element.style.display = 'none'
document.body.appendChild(element)
element.click()
document.body.removeChild(element)
}
async function startDownload() {
inProgress.value = true
failed.value = false
try {
// Loading the peer gives us access to the correct filename. Access rights are enforced by the backend,
// so only the owner (or an administrator) is able to load the peer and its configuration.
await peers.LoadPeer(peerId)
await peers.LoadPeerConfig(peerId, configStyle)
const config = peers.configuration
if (!config) {
throw new Error("empty configuration")
}
const filename = (peers.peer && peers.peer.Filename) ? peers.peer.Filename : "WireGuard-Tunnel.conf"
triggerBrowserDownload(filename, config)
notify({
title: t('peer-config-download.success-title'),
text: t('peer-config-download.success-message'),
type: 'success',
})
inProgress.value = false
} catch (e) {
console.error("Failed to download peer configuration:", e)
failed.value = true
inProgress.value = false
const auth = authStore()
if (auth.IsAuthenticated) {
notify({
title: t('peer-config-download.error-title'),
text: t('peer-config-download.error-message'),
type: 'error',
})
}
}
}
onMounted(async () => {
const rawId = route.params.id
try {
peerId = base64_url_decode(rawId)
} catch (e) {
peerId = rawId // fall back to the raw value if it is not base64-url encoded
}
const styleParam = route.query.style
if (styleParam === "wgquick" || styleParam === "raw") {
configStyle = styleParam
}
await startDownload()
})
</script>
<template>
<div class="page-header">
<h1>{{ $t('peer-config-download.headline') }}</h1>
</div>
<div class="card border-secondary p-5 text-center">
<div v-if="inProgress">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">...</span>
</div>
<p class="lead">{{ $t('peer-config-download.in-progress') }}</p>
</div>
<div v-else>
<p class="lead">{{ failed ? $t('peer-config-download.error-message') : $t('peer-config-download.success-message') }}</p>
<p class="card-text">{{ $t('peer-config-download.manual-hint') }}</p>
<div class="mt-3">
<button type="button" class="btn btn-primary me-2" @click.prevent="startDownload">{{ $t('peer-config-download.button-retry') }}</button>
<RouterLink :to="{ name: 'home' }" class="btn btn-secondary">{{ $t('peer-config-download.button-home') }}</RouterLink>
</div>
</div>
</div>
</template>