mirror of
https://github.com/h44z/wg-portal.git
synced 2026-09-30 00:31:08 +02:00
* feat: add link-based configuration emails (#725) * fix tests
This commit is contained in:
@@ -3,8 +3,10 @@
|
|||||||
<module name="wg-portal" />
|
<module name="wg-portal" />
|
||||||
<working_directory value="$PROJECT_DIR$" />
|
<working_directory value="$PROJECT_DIR$" />
|
||||||
<envs>
|
<envs>
|
||||||
<env name="SESSION_SECRET" value="extremlybad" />
|
|
||||||
<env name="LOG_LEVEL" value="trace" />
|
<env name="LOG_LEVEL" value="trace" />
|
||||||
|
<env name="SESSION_SECRET" value="extremlybad" />
|
||||||
|
<env name="WG_PORTAL_MAIL_PORT" value="1025" />
|
||||||
|
<env name="WG_PORTAL_MAIL_LINK_ONLY" value="true" />
|
||||||
</envs>
|
</envs>
|
||||||
<sudo value="true" />
|
<sudo value="true" />
|
||||||
<kind value="PACKAGE" />
|
<kind value="PACKAGE" />
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ templates and receive the following data fields, depending on the email type:
|
|||||||
- `PortalName` (string) - site title/company name
|
- `PortalName` (string) - site title/company name
|
||||||
- `User` (*domain.User) - the recipient user (may be partially populated when sending to a peer email)
|
- `User` (*domain.User) - the recipient user (may be partially populated when sending to a peer email)
|
||||||
- Link email (`mail_with_link.*`):
|
- Link email (`mail_with_link.*`):
|
||||||
- `Link` (string) - the download link
|
- `Link` (string) - a deep link to the WireGuard Portal web UI that starts the configuration download.
|
||||||
|
Recipients who are not logged in are redirected to the login page first; the download starts automatically
|
||||||
|
once they have authenticated successfully.
|
||||||
- Attachment email (`mail_with_attachment.*`):
|
- Attachment email (`mail_with_attachment.*`):
|
||||||
- `ConfigFileName` (string) - filename of the attached WireGuard config
|
- `ConfigFileName` (string) - filename of the attached WireGuard config
|
||||||
- `QrcodePngName` (string) - CID content-id of the embedded QR code image
|
- `QrcodePngName` (string) - CID content-id of the embedded QR code image
|
||||||
|
|||||||
+17
-3
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink, RouterView } from 'vue-router';
|
import { RouterLink, RouterView } from 'vue-router';
|
||||||
|
import router, { publicPages } from '@/router';
|
||||||
import {computed, getCurrentInstance, nextTick, onMounted, ref} from "vue";
|
import {computed, getCurrentInstance, nextTick, onMounted, ref} from "vue";
|
||||||
import { authStore } from "./stores/auth";
|
import { authStore } from "./stores/auth";
|
||||||
import { securityStore } from "./stores/security";
|
import { securityStore } from "./stores/security";
|
||||||
@@ -24,14 +25,27 @@ onMounted(async () => {
|
|||||||
|
|
||||||
let wasLoggedIn = auth.IsAuthenticated;
|
let wasLoggedIn = auth.IsAuthenticated;
|
||||||
try {
|
try {
|
||||||
await auth.LoadSession();
|
await auth.EnsureSession();
|
||||||
await settings.LoadSettings(); // only logs errors, does not throw
|
await settings.LoadSettings(); // only logs errors, does not throw
|
||||||
|
|
||||||
console.log("WireGuard Portal session is valid");
|
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) {
|
} catch (e) {
|
||||||
if (wasLoggedIn) {
|
if (wasLoggedIn) {
|
||||||
console.log("WireGuard Portal invalid - logging out");
|
console.log("WireGuard Portal session invalid");
|
||||||
await auth.Logout();
|
const currentRoute = router.currentRoute.value;
|
||||||
|
if (currentRoute && !publicPages.includes(currentRoute.path)) {
|
||||||
|
auth.SetReturnUrl(currentRoute.fullPath);
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,11 @@ export function base64_url_encode(input) {
|
|||||||
output = output.replaceAll('/', '_')
|
output = output.replaceAll('/', '_')
|
||||||
output = output.replaceAll('=', '-')
|
output = output.replaceAll('=', '-')
|
||||||
return output
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64_url_decode(input) {
|
||||||
|
let output = input.replaceAll('-', '=')
|
||||||
|
output = output.replaceAll('_', '/')
|
||||||
|
output = output.replaceAll('.', '+')
|
||||||
|
return atob(output)
|
||||||
}
|
}
|
||||||
@@ -81,9 +81,9 @@ function handleResponse(response) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const auth = authStore();
|
const auth = authStore();
|
||||||
if ([401, 403].includes(response.status) && auth.IsAuthenticated) {
|
if ([401, 403].includes(response.status) && auth.IsAuthenticated) {
|
||||||
console.log("automatic logout initiated...");
|
console.log("unauthorized response received, redirecting to login...");
|
||||||
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
|
// handle unauthorized session without clearing return url
|
||||||
auth.Logout();
|
auth.HandleUnauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = (data && data.Message) || response.statusText;
|
const error = (data && data.Message) || response.statusText;
|
||||||
|
|||||||
@@ -32,6 +32,17 @@
|
|||||||
"button": "Anmelden",
|
"button": "Anmelden",
|
||||||
"button-webauthn": "Passkey verwenden"
|
"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": {
|
"menu": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"interfaces": "Schnittstellen",
|
"interfaces": "Schnittstellen",
|
||||||
|
|||||||
@@ -32,6 +32,17 @@
|
|||||||
"button": "Sign in",
|
"button": "Sign in",
|
||||||
"button-webauthn": "Use Passkey"
|
"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": {
|
"menu": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"interfaces": "Interfaces",
|
"interfaces": "Interfaces",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {authStore} from '@/stores/auth'
|
|||||||
import {securityStore} from '@/stores/security'
|
import {securityStore} from '@/stores/security'
|
||||||
import {notify} from "@kyvg/vue3-notification";
|
import {notify} from "@kyvg/vue3-notification";
|
||||||
|
|
||||||
|
export const publicPages = ['/', '/login', '/key-generator', '/ip-calculator']
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
// No base argument: createWebHashHistory() defaults to location.pathname + location.search,
|
// No base argument: createWebHashHistory() defaults to location.pathname + location.search,
|
||||||
// which is correct for /app/, {web.base_path}/app/ and the dev server at /.
|
// 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.
|
// which is lazy-loaded when the route is visited.
|
||||||
component: () => import('../views/ProfileView.vue')
|
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',
|
path: '/settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
@@ -86,12 +96,14 @@ router.beforeEach(async (to) => {
|
|||||||
const auth = authStore()
|
const auth = authStore()
|
||||||
|
|
||||||
// check if the request was a successful oauth login
|
// check if the request was a successful oauth login
|
||||||
if ('wgLoginState' in to.query && !auth.IsAuthenticated) {
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
const state = to.query['wgLoginState']
|
const oauthState = to.query['wgLoginState'] || searchParams.get('wgLoginState')
|
||||||
const returnUrl = auth.ReturnUrl
|
|
||||||
console.log("Oauth login callback:", state)
|
|
||||||
|
|
||||||
if (state === "success") {
|
if (oauthState && !auth.IsAuthenticated) {
|
||||||
|
const returnUrl = auth.ReturnUrl
|
||||||
|
console.log("Oauth login callback:", oauthState)
|
||||||
|
|
||||||
|
if (oauthState === "success") {
|
||||||
try {
|
try {
|
||||||
const uid = await auth.LoadSession()
|
const uid = await auth.LoadSession()
|
||||||
console.log("Oauth login completed for UID:", uid)
|
console.log("Oauth login completed for UID:", uid)
|
||||||
@@ -99,12 +111,16 @@ router.beforeEach(async (to) => {
|
|||||||
|
|
||||||
notify({
|
notify({
|
||||||
title: "Logged in",
|
title: "Logged in",
|
||||||
text: "Authentication suceeded!",
|
text: "Authentication succeeded!",
|
||||||
type: 'success',
|
type: 'success',
|
||||||
})
|
})
|
||||||
|
|
||||||
auth.ResetReturnUrl()
|
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) {
|
} catch (e) {
|
||||||
notify({
|
notify({
|
||||||
title: "Login failed!",
|
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
|
// 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)
|
const authRequired = !publicPages.includes(to.path)
|
||||||
|
|
||||||
if (authRequired && !auth.IsAuthenticated) {
|
if (authRequired && !auth.IsAuthenticated) {
|
||||||
|
|||||||
+58
-16
@@ -3,7 +3,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { notify } from "@kyvg/vue3-notification";
|
import { notify } from "@kyvg/vue3-notification";
|
||||||
import { apiWrapper } from '@/helpers/fetch-wrapper'
|
import { apiWrapper } from '@/helpers/fetch-wrapper'
|
||||||
import { websocketWrapper } from '@/helpers/websocket-wrapper'
|
import { websocketWrapper } from '@/helpers/websocket-wrapper'
|
||||||
import router from '../router'
|
import router, { publicPages } from '../router'
|
||||||
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
|
import { browserSupportsWebAuthn,startRegistration,startAuthentication } from '@simplewebauthn/browser';
|
||||||
import {base64_url_encode} from "@/helpers/encoding";
|
import {base64_url_encode} from "@/helpers/encoding";
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ export const authStore = defineStore('auth',{
|
|||||||
returnUrl: localStorage.getItem('returnUrl'),
|
returnUrl: localStorage.getItem('returnUrl'),
|
||||||
webAuthnCredentials: [],
|
webAuthnCredentials: [],
|
||||||
fetching: false,
|
fetching: false,
|
||||||
|
sessionChecked: false,
|
||||||
|
sessionPromise: null,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
UserIdentifier: (state) => state.user?.Identifier || 'unknown',
|
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.
|
// LoadSession returns promise that might have been rejected if the session was not authenticated.
|
||||||
async LoadSession() {
|
async LoadSession() {
|
||||||
return apiWrapper.get(`/auth/session`)
|
return apiWrapper.get(`/auth/session`)
|
||||||
.then(session => {
|
.then(session => {
|
||||||
|
this.sessionChecked = true
|
||||||
if (session.LoggedIn === true) {
|
if (session.LoggedIn === true) {
|
||||||
this.ResetReturnUrl()
|
|
||||||
this.setUserInfo(session)
|
this.setUserInfo(session)
|
||||||
return session.UserIdentifier
|
return session.UserIdentifier
|
||||||
} else {
|
} else {
|
||||||
@@ -69,6 +88,7 @@ export const authStore = defineStore('auth',{
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
this.sessionChecked = true
|
||||||
this.setUserInfo(null)
|
this.setUserInfo(null)
|
||||||
return Promise.reject(err)
|
return Promise.reject(err)
|
||||||
})
|
})
|
||||||
@@ -94,7 +114,10 @@ export const authStore = defineStore('auth',{
|
|||||||
async Login(username, password) {
|
async Login(username, password) {
|
||||||
return apiWrapper.post(`/auth/login`, { username, password })
|
return apiWrapper.post(`/auth/login`, { username, password })
|
||||||
.then(user => {
|
.then(user => {
|
||||||
this.ResetReturnUrl()
|
if (!user || !user.Identifier) {
|
||||||
|
this.setUserInfo(null)
|
||||||
|
return Promise.reject(new Error("login failed"))
|
||||||
|
}
|
||||||
this.setUserInfo(user)
|
this.setUserInfo(user)
|
||||||
return user.Identifier
|
return user.Identifier
|
||||||
})
|
})
|
||||||
@@ -104,9 +127,19 @@ export const authStore = defineStore('auth',{
|
|||||||
return Promise.reject(new Error("login failed"))
|
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() {
|
async Logout() {
|
||||||
this.setUserInfo(null)
|
this.setUserInfo(null)
|
||||||
this.ResetReturnUrl() // just to be sure^^
|
this.ResetReturnUrl() // just to be sure^^
|
||||||
|
this.sessionChecked = true
|
||||||
|
|
||||||
let logoutResponse = null
|
let logoutResponse = null
|
||||||
try {
|
try {
|
||||||
@@ -259,8 +292,11 @@ export const authStore = defineStore('auth',{
|
|||||||
console.log("Finishing WebAuthn login ...")
|
console.log("Finishing WebAuthn login ...")
|
||||||
return apiWrapper.post(`/auth/webauthn/login/finish`, asseResp)
|
return apiWrapper.post(`/auth/webauthn/login/finish`, asseResp)
|
||||||
.then(user => {
|
.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)
|
console.log("Passkey login finished successfully for user:", user.Identifier)
|
||||||
this.ResetReturnUrl()
|
|
||||||
this.setUserInfo(user)
|
this.setUserInfo(user)
|
||||||
return user.Identifier
|
return user.Identifier
|
||||||
})
|
})
|
||||||
@@ -284,28 +320,34 @@ export const authStore = defineStore('auth',{
|
|||||||
// -- internal setters
|
// -- internal setters
|
||||||
setUserInfo(userInfo) {
|
setUserInfo(userInfo) {
|
||||||
// store user details and jwt in local storage to keep user logged in between page refreshes
|
// store user details and jwt in local storage to keep user logged in between page refreshes
|
||||||
if (userInfo) {
|
if (userInfo && (userInfo.Identifier || userInfo.UserIdentifier)) {
|
||||||
if ('UserIdentifier' in userInfo) { // session object
|
if ('UserIdentifier' in userInfo && userInfo.UserIdentifier) { // session object
|
||||||
this.user = {
|
this.user = {
|
||||||
Identifier: userInfo['UserIdentifier'],
|
Identifier: userInfo['UserIdentifier'],
|
||||||
Firstname: userInfo['UserFirstname'],
|
Firstname: userInfo['UserFirstname'] || '',
|
||||||
Lastname: userInfo['UserLastname'],
|
Lastname: userInfo['UserLastname'] || '',
|
||||||
Email: userInfo['UserEmail'],
|
Email: userInfo['UserEmail'] || '',
|
||||||
IsAdmin: userInfo['IsAdmin']
|
IsAdmin: userInfo['IsAdmin'] || false
|
||||||
}
|
}
|
||||||
} else { // user object
|
} else if ('Identifier' in userInfo && userInfo.Identifier) { // user object
|
||||||
this.user = {
|
this.user = {
|
||||||
Identifier: userInfo['Identifier'],
|
Identifier: userInfo['Identifier'],
|
||||||
Firstname: userInfo['Firstname'],
|
Firstname: userInfo['Firstname'] || '',
|
||||||
Lastname: userInfo['Lastname'],
|
Lastname: userInfo['Lastname'] || '',
|
||||||
Email: userInfo['Email'],
|
Email: userInfo['Email'] || '',
|
||||||
IsAdmin: userInfo['IsAdmin']
|
IsAdmin: userInfo['IsAdmin'] || false
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.user = null
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.user = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.user) {
|
||||||
localStorage.setItem('user', JSON.stringify(this.user))
|
localStorage.setItem('user', JSON.stringify(this.user))
|
||||||
websocketWrapper.connect()
|
websocketWrapper.connect()
|
||||||
} else {
|
} else {
|
||||||
this.user = null
|
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
websocketWrapper.disconnect()
|
websocketWrapper.disconnect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const settings = settingsStore()
|
|||||||
const loggingIn = ref(false)
|
const loggingIn = ref(false)
|
||||||
const username = ref("")
|
const username = ref("")
|
||||||
const password = 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 usernameInvalid = computed(() => username.value === "")
|
||||||
const passwordInvalid = computed(() => password.value === "")
|
const passwordInvalid = computed(() => password.value === "")
|
||||||
@@ -22,12 +26,20 @@ const showLoginForm = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
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()
|
await settings.LoadSettings()
|
||||||
})
|
})
|
||||||
|
|
||||||
const login = async function () {
|
const login = async function () {
|
||||||
console.log("Performing login for user:", username.value);
|
console.log("Performing login for user:", username.value);
|
||||||
loggingIn.value = true;
|
loggingIn.value = true;
|
||||||
|
const dest = returnUrl.value && returnUrl.value !== '/login' ? returnUrl.value : (auth.ReturnUrl || '/')
|
||||||
auth.Login(username.value, password.value)
|
auth.Login(username.value, password.value)
|
||||||
.then(uid => {
|
.then(uid => {
|
||||||
notify({
|
notify({
|
||||||
@@ -37,7 +49,8 @@ const login = async function () {
|
|||||||
});
|
});
|
||||||
loggingIn.value = false;
|
loggingIn.value = false;
|
||||||
settings.LoadSettings(); // reload full settings
|
settings.LoadSettings(); // reload full settings
|
||||||
router.push(auth.ReturnUrl);
|
auth.ResetReturnUrl();
|
||||||
|
router.push(dest);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
notify({
|
notify({
|
||||||
@@ -55,6 +68,7 @@ const login = async function () {
|
|||||||
const loginWebAuthn = async function () {
|
const loginWebAuthn = async function () {
|
||||||
console.log("Performing webauthn login");
|
console.log("Performing webauthn login");
|
||||||
loggingIn.value = true;
|
loggingIn.value = true;
|
||||||
|
const dest = returnUrl.value && returnUrl.value !== '/login' ? returnUrl.value : (auth.ReturnUrl || '/')
|
||||||
auth.LoginWebAuthn()
|
auth.LoginWebAuthn()
|
||||||
.then(uid => {
|
.then(uid => {
|
||||||
notify({
|
notify({
|
||||||
@@ -64,7 +78,8 @@ const loginWebAuthn = async function () {
|
|||||||
});
|
});
|
||||||
loggingIn.value = false;
|
loggingIn.value = false;
|
||||||
settings.LoadSettings(); // reload full settings
|
settings.LoadSettings(); // reload full settings
|
||||||
router.push(auth.ReturnUrl);
|
auth.ResetReturnUrl();
|
||||||
|
router.push(dest);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
notify({
|
notify({
|
||||||
@@ -83,6 +98,9 @@ const externalLogin = function (provider) {
|
|||||||
console.log("Performing external login for provider", provider.Identifier);
|
console.log("Performing external login for provider", provider.Identifier);
|
||||||
loggingIn.value = true;
|
loggingIn.value = true;
|
||||||
console.log(router.currentRoute.value);
|
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
|
// 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 /
|
// (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.
|
// 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>
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Base64UrlDecode decodes a base64 url encoded string.
|
|
||||||
// In comparison to the standard base64 encoding, the url encoding uses - instead of + and _ instead of /
|
|
||||||
// as well as . instead of =.
|
|
||||||
func Base64UrlDecode(in string) string {
|
|
||||||
in = strings.ReplaceAll(in, "-", "=")
|
|
||||||
in = strings.ReplaceAll(in, "_", "/")
|
|
||||||
in = strings.ReplaceAll(in, ".", "+")
|
|
||||||
|
|
||||||
output, _ := base64.StdEncoding.DecodeString(in)
|
|
||||||
return string(output)
|
|
||||||
}
|
|
||||||
@@ -383,12 +383,6 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, o
|
|||||||
// @Router /auth/login [post]
|
// @Router /auth/login [post]
|
||||||
func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
|
func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
currentSession := e.session.GetData(r.Context())
|
|
||||||
if currentSession.LoggedIn {
|
|
||||||
respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "already logged in"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var loginData struct {
|
var loginData struct {
|
||||||
Username string `json:"username" binding:"required,min=2"`
|
Username string `json:"username" binding:"required,min=2"`
|
||||||
Password string `json:"password" binding:"required,min=4"`
|
Password string `json:"password" binding:"required,min=4"`
|
||||||
@@ -570,7 +564,7 @@ func (e AuthEndpoint) handleWebAuthnCredentialsDelete() http.HandlerFunc {
|
|||||||
|
|
||||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||||
|
|
||||||
credentialId := Base64UrlDecode(request.Path(r, "id"))
|
credentialId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
|
|
||||||
credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId)
|
credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -605,7 +599,7 @@ func (e AuthEndpoint) handleWebAuthnCredentialsPut() http.HandlerFunc {
|
|||||||
|
|
||||||
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
|
||||||
|
|
||||||
credentialId := Base64UrlDecode(request.Path(r, "id"))
|
credentialId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
var req model.WebAuthnCredentialRequest
|
var req model.WebAuthnCredentialRequest
|
||||||
if err := request.BodyJson(r, &req); err != nil {
|
if err := request.BodyJson(r, &req); err != nil {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/h44z/wg-portal/internal/config"
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testSession struct {
|
type testSession struct {
|
||||||
@@ -115,3 +118,55 @@ func TestAuthEndpointFrontendUrlUsesBasePathAppMount(t *testing.T) {
|
|||||||
t.Fatalf("expected frontend URL %q, got %q", want, got)
|
t.Fatalf("expected frontend URL %q, got %q", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type dummyAuthService struct {
|
||||||
|
loginErr error
|
||||||
|
user *domain.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d dummyAuthService) GetExternalLoginProviders(_ context.Context) []domain.LoginProviderInfo {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (d dummyAuthService) PlainLogin(_ context.Context, username, password string) (*domain.User, error) {
|
||||||
|
if d.loginErr != nil {
|
||||||
|
return nil, d.loginErr
|
||||||
|
}
|
||||||
|
return d.user, nil
|
||||||
|
}
|
||||||
|
func (d dummyAuthService) OauthLoginStep1(_ context.Context, _ string) (string, string, string, string, error) {
|
||||||
|
return "", "", "", "", nil
|
||||||
|
}
|
||||||
|
func (d dummyAuthService) OauthLoginStep2(_ context.Context, _, _, _, _ string) (*domain.User, string, error) {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
func (d dummyAuthService) OauthProviderLogoutUrl(_, _, _ string) (string, bool) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
type dummyValidator struct{}
|
||||||
|
|
||||||
|
func (d dummyValidator) Struct(_ any) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthEndpointHandleLoginPostRejectsInvalidCredentialsEvenIfSessionDirty(t *testing.T) {
|
||||||
|
session := &testSession{data: SessionData{
|
||||||
|
LoggedIn: true,
|
||||||
|
UserIdentifier: "previous-user",
|
||||||
|
}}
|
||||||
|
ep := AuthEndpoint{
|
||||||
|
session: session,
|
||||||
|
authService: dummyAuthService{loginErr: errors.New("auth failed")},
|
||||||
|
validate: dummyValidator{},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v0/auth/login", strings.NewReader(`{"username":"admin","password":"wrongpassword"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ep.handleLoginPost().ServeHTTP(res, req)
|
||||||
|
|
||||||
|
if res.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected status %d (Unauthorized), got %d", http.StatusUnauthorized, res.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func (e InterfaceEndpoint) handleAllGet() http.HandlerFunc {
|
|||||||
// @Router /interface/get/{id} [get]
|
// @Router /interface/get/{id} [get]
|
||||||
func (e InterfaceEndpoint) handleSingleGet() http.HandlerFunc {
|
func (e InterfaceEndpoint) handleSingleGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest, model.Error{
|
respond.JSON(w, http.StatusBadRequest, model.Error{
|
||||||
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
||||||
@@ -170,7 +170,7 @@ func (e InterfaceEndpoint) handleSingleGet() http.HandlerFunc {
|
|||||||
// @Router /interface/config/{id} [get]
|
// @Router /interface/config/{id} [get]
|
||||||
func (e InterfaceEndpoint) handleConfigGet() http.HandlerFunc {
|
func (e InterfaceEndpoint) handleConfigGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest, model.Error{
|
respond.JSON(w, http.StatusBadRequest, model.Error{
|
||||||
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
||||||
@@ -212,7 +212,7 @@ func (e InterfaceEndpoint) handleConfigGet() http.HandlerFunc {
|
|||||||
// @Router /interface/{id} [put]
|
// @Router /interface/{id} [put]
|
||||||
func (e InterfaceEndpoint) handleUpdatePut() http.HandlerFunc {
|
func (e InterfaceEndpoint) handleUpdatePut() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
||||||
@@ -293,7 +293,7 @@ func (e InterfaceEndpoint) handleCreatePost() http.HandlerFunc {
|
|||||||
// @Router /interface/peers/{id} [get]
|
// @Router /interface/peers/{id} [get]
|
||||||
func (e InterfaceEndpoint) handlePeersGet() http.HandlerFunc {
|
func (e InterfaceEndpoint) handlePeersGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest, model.Error{
|
respond.JSON(w, http.StatusBadRequest, model.Error{
|
||||||
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
||||||
@@ -326,7 +326,7 @@ func (e InterfaceEndpoint) handlePeersGet() http.HandlerFunc {
|
|||||||
// @Router /interface/{id} [delete]
|
// @Router /interface/{id} [delete]
|
||||||
func (e InterfaceEndpoint) handleDelete() http.HandlerFunc {
|
func (e InterfaceEndpoint) handleDelete() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
||||||
@@ -358,7 +358,7 @@ func (e InterfaceEndpoint) handleDelete() http.HandlerFunc {
|
|||||||
// @Router /interface/{id}/save-config [post]
|
// @Router /interface/{id}/save-config [post]
|
||||||
func (e InterfaceEndpoint) handleSaveConfigPost() http.HandlerFunc {
|
func (e InterfaceEndpoint) handleSaveConfigPost() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
||||||
@@ -391,7 +391,7 @@ func (e InterfaceEndpoint) handleSaveConfigPost() http.HandlerFunc {
|
|||||||
// @Router /interface/{id}/apply-peer-defaults [post]
|
// @Router /interface/{id}/apply-peer-defaults [post]
|
||||||
func (e InterfaceEndpoint) handleApplyPeerDefaultsPost() http.HandlerFunc {
|
func (e InterfaceEndpoint) handleApplyPeerDefaultsPost() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
||||||
@@ -438,7 +438,7 @@ func (e InterfaceEndpoint) handleApplyPeerDefaultsPost() http.HandlerFunc {
|
|||||||
// @Router /interface/{id}/create-default-peers [post]
|
// @Router /interface/{id}/create-default-peers [post]
|
||||||
func (e InterfaceEndpoint) handleCreateDefaultPeersPost() http.HandlerFunc {
|
func (e InterfaceEndpoint) handleCreateDefaultPeersPost() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) {
|
|||||||
// @Router /peer/iface/{iface}/all [get]
|
// @Router /peer/iface/{iface}/all [get]
|
||||||
func (e PeerEndpoint) handleAllGet() http.HandlerFunc {
|
func (e PeerEndpoint) handleAllGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
interfaceId := Base64UrlDecode(request.Path(r, "iface"))
|
interfaceId := domain.Base64UrlDecode(request.Path(r, "iface"))
|
||||||
if interfaceId == "" {
|
if interfaceId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
||||||
@@ -138,7 +138,7 @@ func (e PeerEndpoint) handleAllGet() http.HandlerFunc {
|
|||||||
// @Router /peer/{id} [get]
|
// @Router /peer/{id} [get]
|
||||||
func (e PeerEndpoint) handleSingleGet() http.HandlerFunc {
|
func (e PeerEndpoint) handleSingleGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
peerId := Base64UrlDecode(request.Path(r, "id"))
|
peerId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if peerId == "" {
|
if peerId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing id parameter"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing id parameter"})
|
||||||
@@ -169,7 +169,7 @@ func (e PeerEndpoint) handleSingleGet() http.HandlerFunc {
|
|||||||
// @Router /peer/iface/{iface}/prepare [get]
|
// @Router /peer/iface/{iface}/prepare [get]
|
||||||
func (e PeerEndpoint) handlePrepareGet() http.HandlerFunc {
|
func (e PeerEndpoint) handlePrepareGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
interfaceId := Base64UrlDecode(request.Path(r, "iface"))
|
interfaceId := domain.Base64UrlDecode(request.Path(r, "iface"))
|
||||||
if interfaceId == "" {
|
if interfaceId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
||||||
@@ -201,7 +201,7 @@ func (e PeerEndpoint) handlePrepareGet() http.HandlerFunc {
|
|||||||
// @Router /peer/iface/{iface}/new [post]
|
// @Router /peer/iface/{iface}/new [post]
|
||||||
func (e PeerEndpoint) handleCreatePost() http.HandlerFunc {
|
func (e PeerEndpoint) handleCreatePost() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
interfaceId := Base64UrlDecode(request.Path(r, "iface"))
|
interfaceId := domain.Base64UrlDecode(request.Path(r, "iface"))
|
||||||
if interfaceId == "" {
|
if interfaceId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
||||||
@@ -249,7 +249,7 @@ func (e PeerEndpoint) handleCreatePost() http.HandlerFunc {
|
|||||||
// @Router /peer/iface/{iface}/multiplenew [post]
|
// @Router /peer/iface/{iface}/multiplenew [post]
|
||||||
func (e PeerEndpoint) handleCreateMultiplePost() http.HandlerFunc {
|
func (e PeerEndpoint) handleCreateMultiplePost() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
interfaceId := Base64UrlDecode(request.Path(r, "iface"))
|
interfaceId := domain.Base64UrlDecode(request.Path(r, "iface"))
|
||||||
if interfaceId == "" {
|
if interfaceId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
||||||
@@ -292,7 +292,7 @@ func (e PeerEndpoint) handleCreateMultiplePost() http.HandlerFunc {
|
|||||||
// @Router /peer/{id} [put]
|
// @Router /peer/{id} [put]
|
||||||
func (e PeerEndpoint) handleUpdatePut() http.HandlerFunc {
|
func (e PeerEndpoint) handleUpdatePut() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
peerId := Base64UrlDecode(request.Path(r, "id"))
|
peerId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if peerId == "" {
|
if peerId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing id parameter"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing id parameter"})
|
||||||
@@ -339,7 +339,7 @@ func (e PeerEndpoint) handleUpdatePut() http.HandlerFunc {
|
|||||||
// @Router /peer/{id} [delete]
|
// @Router /peer/{id} [delete]
|
||||||
func (e PeerEndpoint) handleDelete() http.HandlerFunc {
|
func (e PeerEndpoint) handleDelete() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
|
||||||
return
|
return
|
||||||
@@ -370,7 +370,7 @@ func (e PeerEndpoint) handleDelete() http.HandlerFunc {
|
|||||||
// @Router /peer/config/{id} [get]
|
// @Router /peer/config/{id} [get]
|
||||||
func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
|
func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest, model.Error{
|
respond.JSON(w, http.StatusBadRequest, model.Error{
|
||||||
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
||||||
@@ -415,7 +415,7 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc {
|
|||||||
// @Router /peer/config-qr/{id} [get]
|
// @Router /peer/config-qr/{id} [get]
|
||||||
func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
|
func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest, model.Error{
|
respond.JSON(w, http.StatusBadRequest, model.Error{
|
||||||
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
Code: http.StatusInternalServerError, Message: "missing id parameter",
|
||||||
@@ -504,7 +504,7 @@ func (e PeerEndpoint) handleEmailPost() http.HandlerFunc {
|
|||||||
// @Router /peer/iface/{iface}/stats [get]
|
// @Router /peer/iface/{iface}/stats [get]
|
||||||
func (e PeerEndpoint) handleStatsGet() http.HandlerFunc {
|
func (e PeerEndpoint) handleStatsGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
interfaceId := Base64UrlDecode(request.Path(r, "iface"))
|
interfaceId := domain.Base64UrlDecode(request.Path(r, "iface"))
|
||||||
if interfaceId == "" {
|
if interfaceId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (e UserEndpoint) handleAllGet() http.HandlerFunc {
|
|||||||
// @Router /user/{id} [get]
|
// @Router /user/{id} [get]
|
||||||
func (e UserEndpoint) handleSingleGet() http.HandlerFunc {
|
func (e UserEndpoint) handleSingleGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"})
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"})
|
||||||
return
|
return
|
||||||
@@ -156,7 +156,7 @@ func (e UserEndpoint) handleSingleGet() http.HandlerFunc {
|
|||||||
// @Router /user/{id} [put]
|
// @Router /user/{id} [put]
|
||||||
func (e UserEndpoint) handleUpdatePut() http.HandlerFunc {
|
func (e UserEndpoint) handleUpdatePut() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"})
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"})
|
||||||
return
|
return
|
||||||
@@ -236,7 +236,7 @@ func (e UserEndpoint) handleCreatePost() http.HandlerFunc {
|
|||||||
// @Router /user/{id}/peers [get]
|
// @Router /user/{id}/peers [get]
|
||||||
func (e UserEndpoint) handlePeersGet() http.HandlerFunc {
|
func (e UserEndpoint) handlePeersGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userId := Base64UrlDecode(request.Path(r, "id"))
|
userId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if userId == "" {
|
if userId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
||||||
@@ -267,7 +267,7 @@ func (e UserEndpoint) handlePeersGet() http.HandlerFunc {
|
|||||||
// @Router /user/{id}/stats [get]
|
// @Router /user/{id}/stats [get]
|
||||||
func (e UserEndpoint) handleStatsGet() http.HandlerFunc {
|
func (e UserEndpoint) handleStatsGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userId := Base64UrlDecode(request.Path(r, "id"))
|
userId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if userId == "" {
|
if userId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
||||||
@@ -298,7 +298,7 @@ func (e UserEndpoint) handleStatsGet() http.HandlerFunc {
|
|||||||
// @Router /user/{id}/interfaces [get]
|
// @Router /user/{id}/interfaces [get]
|
||||||
func (e UserEndpoint) handleInterfacesGet() http.HandlerFunc {
|
func (e UserEndpoint) handleInterfacesGet() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userId := Base64UrlDecode(request.Path(r, "id"))
|
userId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if userId == "" {
|
if userId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
||||||
@@ -329,7 +329,7 @@ func (e UserEndpoint) handleInterfacesGet() http.HandlerFunc {
|
|||||||
// @Router /user/{id} [delete]
|
// @Router /user/{id} [delete]
|
||||||
func (e UserEndpoint) handleDelete() http.HandlerFunc {
|
func (e UserEndpoint) handleDelete() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := Base64UrlDecode(request.Path(r, "id"))
|
id := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"})
|
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"})
|
||||||
return
|
return
|
||||||
@@ -358,7 +358,7 @@ func (e UserEndpoint) handleDelete() http.HandlerFunc {
|
|||||||
// @Router /user/{id}/api/enable [post]
|
// @Router /user/{id}/api/enable [post]
|
||||||
func (e UserEndpoint) handleApiEnablePost() http.HandlerFunc {
|
func (e UserEndpoint) handleApiEnablePost() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userId := Base64UrlDecode(request.Path(r, "id"))
|
userId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if userId == "" {
|
if userId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
||||||
@@ -388,7 +388,7 @@ func (e UserEndpoint) handleApiEnablePost() http.HandlerFunc {
|
|||||||
// @Router /user/{id}/api/disable [post]
|
// @Router /user/{id}/api/disable [post]
|
||||||
func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc {
|
func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userId := Base64UrlDecode(request.Path(r, "id"))
|
userId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if userId == "" {
|
if userId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
||||||
@@ -418,7 +418,7 @@ func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc {
|
|||||||
// @Router /user/{id}/change-password [post]
|
// @Router /user/{id}/change-password [post]
|
||||||
func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc {
|
func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userId := Base64UrlDecode(request.Path(r, "id"))
|
userId := domain.Base64UrlDecode(request.Path(r, "id"))
|
||||||
if userId == "" {
|
if userId == "" {
|
||||||
respond.JSON(w, http.StatusBadRequest,
|
respond.JSON(w, http.StatusBadRequest,
|
||||||
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"})
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func (h AuthenticationHandler) UserIdMatch(idParameter string) func(next http.Ha
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionUserId := domain.UserIdentifier(session.UserIdentifier)
|
sessionUserId := domain.UserIdentifier(session.UserIdentifier)
|
||||||
requestUserId := domain.UserIdentifier(Base64UrlDecode(request.Path(r, idParameter)))
|
requestUserId := domain.UserIdentifier(domain.Base64UrlDecode(request.Path(r, idParameter)))
|
||||||
|
|
||||||
if sessionUserId != requestUserId {
|
if sessionUserId != requestUserId {
|
||||||
// Abort the request with the appropriate error code
|
// Abort the request with the appropriate error code
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/h44z/wg-portal/internal/config"
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
"github.com/h44z/wg-portal/internal/domain"
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
@@ -135,7 +136,8 @@ func (m Manager) sendPeerEmail(
|
|||||||
mailOptions domain.MailOptions
|
mailOptions domain.MailOptions
|
||||||
)
|
)
|
||||||
if linkOnly {
|
if linkOnly {
|
||||||
txtMail, htmlMail, err = m.tplHandler.GetConfigMail(user, "deep link TBD")
|
configDownloadLink := m.getPeerConfigDownloadLink(peer.Identifier, style)
|
||||||
|
txtMail, htmlMail, err = m.tplHandler.GetConfigMail(user, configDownloadLink)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get mail body: %w", err)
|
return fmt.Errorf("failed to get mail body: %w", err)
|
||||||
}
|
}
|
||||||
@@ -182,6 +184,22 @@ func (m Manager) sendPeerEmail(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getPeerConfigDownloadLink builds an absolute link that points to the peer configuration download
|
||||||
|
// page of the WireGuard Portal web frontend. The link is used in link-only emails.
|
||||||
|
//
|
||||||
|
// The link is a "deep link" into the single-page application (hash based routing). When the recipient
|
||||||
|
// opens the link while not being authenticated, the frontend redirects them to the login page first and
|
||||||
|
// only starts the configuration download after a successful authentication.
|
||||||
|
func (m Manager) getPeerConfigDownloadLink(peerId domain.PeerIdentifier, style string) string {
|
||||||
|
encodedId := domain.Base64UrlEncode(string(peerId))
|
||||||
|
link := fmt.Sprintf("%s%s/app/#/peer/config/%s",
|
||||||
|
m.cfg.Web.ExternalUrl, m.cfg.Web.BasePath, encodedId)
|
||||||
|
if style != "" {
|
||||||
|
link += "?style=" + url.QueryEscape(style)
|
||||||
|
}
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, domain.User) {
|
func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, domain.User) {
|
||||||
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
|
user, err := m.users.GetUser(ctx, peer.UserIdentifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/h44z/wg-portal/internal/config"
|
||||||
|
"github.com/h44z/wg-portal/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_base64UrlEncode_isReversibleWithHandlerDecode(t *testing.T) {
|
||||||
|
inputs := []string{
|
||||||
|
"peer-identifier",
|
||||||
|
"aGVsbG8=", // ensure padding characters are handled
|
||||||
|
"abc/def+ghi", // ensure + and / are handled
|
||||||
|
"wgTestKey1234567890==",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, in := range inputs {
|
||||||
|
encoded := domain.Base64UrlEncode(in)
|
||||||
|
|
||||||
|
// The URL-safe variant must not contain characters that are unsafe in URLs.
|
||||||
|
if strings.ContainsAny(encoded, "+/=") {
|
||||||
|
t.Fatalf("encoded value %q still contains unsafe characters", encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := domain.Base64UrlDecode(encoded)
|
||||||
|
if decoded != in {
|
||||||
|
t.Fatalf("round trip failed: got %q, want %q (encoded: %q)", decoded, in, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_getPeerConfigDownloadLink(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
externalUrl string
|
||||||
|
basePath string
|
||||||
|
peerId domain.PeerIdentifier
|
||||||
|
style string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no base path",
|
||||||
|
externalUrl: "https://wg.example.com",
|
||||||
|
basePath: "",
|
||||||
|
peerId: "peer1",
|
||||||
|
style: "wgquick",
|
||||||
|
want: "https://wg.example.com/app/#/peer/config/" + domain.Base64UrlEncode("peer1") + "?style=wgquick",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with base path",
|
||||||
|
externalUrl: "https://wg.example.com",
|
||||||
|
basePath: "/wg",
|
||||||
|
peerId: "peer1",
|
||||||
|
style: "",
|
||||||
|
want: "https://wg.example.com/wg/app/#/peer/config/" + domain.Base64UrlEncode("peer1"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Web.ExternalUrl = tt.externalUrl
|
||||||
|
cfg.Web.BasePath = tt.basePath
|
||||||
|
m := Manager{cfg: cfg}
|
||||||
|
|
||||||
|
got := m.getPeerConfigDownloadLink(tt.peerId, tt.style)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("getPeerConfigDownloadLink() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_GetConfigMail_containsLink(t *testing.T) {
|
||||||
|
handler, err := newTemplateHandler("https://wg.example.com", "WireGuard Portal", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create template handler: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := "https://wg.example.com/app/#/peer/config/abc?style=wgquick"
|
||||||
|
txtReader, htmlReader, err := handler.GetConfigMail(&domain.User{Firstname: "John", Lastname: "Doe"}, link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to render link mail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
txt, _ := io.ReadAll(txtReader)
|
||||||
|
html, _ := io.ReadAll(htmlReader)
|
||||||
|
|
||||||
|
if !strings.Contains(string(txt), link) {
|
||||||
|
t.Errorf("text link mail does not contain the generated link.\n%s", string(txt))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(html), link) {
|
||||||
|
t.Errorf("html link mail does not contain the generated link.\n%s", string(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link mail must not reference the placeholder that was used before the fix.
|
||||||
|
if strings.Contains(string(txt), "deep link TBD") || strings.Contains(string(html), "deep link TBD") {
|
||||||
|
t.Errorf("link mail still contains the placeholder link")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="td container" style="width:650px; min-width:650px; font-size:0pt; line-height:0pt; margin:0; font-weight:normal; padding:55px 0px;">
|
<td class="td container" style="width:650px; min-width:650px; font-size:0pt; line-height:0pt; margin:0; font-weight:normal; padding:55px 0px;">
|
||||||
|
|
||||||
<!-- Article / Image On The Left - Copy On The Right -->
|
<!-- Article / Copy + Download Link -->
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-bottom: 10px;">
|
<td style="padding-bottom: 10px;">
|
||||||
@@ -89,28 +89,28 @@
|
|||||||
<td class="tbrr p30-15" style="padding: 60px 30px; border-radius:26px 26px 0px 0px;" bgcolor="#ffffff">
|
<td class="tbrr p30-15" style="padding: 60px 30px; border-radius:26px 26px 0px 0px;" bgcolor="#ffffff">
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="column-top" width="210" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;">
|
{{if $.User.Firstname}}
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<td class="h4 pb20" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:20px; line-height:28px; text-align:left; padding-bottom:20px;">Hello {{$.User.Firstname}} {{$.User.Lastname}}</td>
|
||||||
|
{{else}}
|
||||||
|
<td class="h4 pb20" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:20px; line-height:28px; text-align:left; padding-bottom:20px;">Hello</td>
|
||||||
|
{{end}}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text pb20" style="color:#000000; font-family:Arial,sans-serif; font-size:14px; line-height:26px; text-align:left; padding-bottom:20px;">You or your administrator probably requested this VPN configuration. Use the button below to download your personal WireGuard configuration file and open it in the WireGuard VPN client to establish a secure VPN connection.</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Button -->
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fluid-img" style="font-size:0pt; line-height:0pt; text-align:left;"><img src="cid:{{$.QrcodePngName}}" width="210" height="210" border="0" alt="" /></td>
|
<td class="blue-button text-button" style="background:#000000; color:#ffffff; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="{{$.Link}}" target="_blank" rel="noopener noreferrer" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Download VPN Configuration</span></a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</th>
|
</td>
|
||||||
<th class="column-empty2" width="30" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;"></th>
|
</tr>
|
||||||
<th class="column-top" width="280" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;">
|
<!-- END Button -->
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<tr>
|
||||||
<tr>
|
<td class="text" style="color:#000000; font-family:Arial,sans-serif; font-size:12px; line-height:22px; text-align:left; padding-top:20px; word-break:break-all;">If the button does not work, copy and paste the following link into your browser:<br /><a href="{{$.Link}}" target="_blank" rel="noopener noreferrer" class="link" style="color:#000000; text-decoration:underline;">{{$.Link}}</a></td>
|
||||||
{{if $.User.Firstname}}
|
|
||||||
<td class="h4 pb20" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:20px; line-height:28px; text-align:left; padding-bottom:20px;">Hello {{$.User.Firstname}} {{$.User.Lastname}}</td>
|
|
||||||
{{else}}
|
|
||||||
<td class="h4 pb20" style="color:#000000; font-family:'Muli', Arial,sans-serif; font-size:20px; line-height:28px; text-align:left; padding-bottom:20px;">Hello</td>
|
|
||||||
{{end}}
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="text pb20" style="color:#000000; font-family:Arial,sans-serif; font-size:14px; line-height:26px; text-align:left; padding-bottom:20px;">You or your administrator probably requested this VPN configuration. Scan the Qrcode or open the attached configuration file ({{$.Peer.GetConfigFileName}}) in the WireGuard VPN client to establish a secure VPN connection.</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<!-- END Article / Image On The Left - Copy On The Right -->
|
<!-- END Article / Copy + Download Link -->
|
||||||
|
|
||||||
<!-- Two Columns / Articles -->
|
<!-- Two Columns / Articles -->
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
@@ -184,4 +184,4 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ Hello,
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
You or your administrator probably requested this VPN configuration.
|
You or your administrator probably requested this VPN configuration.
|
||||||
Scan the attached Qrcode or open the attached configuration file ({{$.ConfigFileName}})
|
Follow the link below to download your personal WireGuard configuration file and open it
|
||||||
in the WireGuard VPN client to establish a secure VPN connection.
|
in the WireGuard VPN client to establish a secure VPN connection:
|
||||||
|
|
||||||
|
{{$.Link}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -21,4 +23,4 @@ https://www.wireguard.com/install/
|
|||||||
|
|
||||||
|
|
||||||
This mail was generated by {{$.PortalName}}.
|
This mail was generated by {{$.PortalName}}.
|
||||||
{{$.PortalUrl}}
|
{{$.PortalUrl}}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Base64UrlDecode decodes a base64 url encoded string.
|
||||||
|
// In comparison to the standard base64 encoding, the url encoding uses - instead of + and _ instead of /
|
||||||
|
// as well as . instead of =.
|
||||||
|
func Base64UrlDecode(in string) string {
|
||||||
|
in = strings.ReplaceAll(in, "-", "=")
|
||||||
|
in = strings.ReplaceAll(in, "_", "/")
|
||||||
|
in = strings.ReplaceAll(in, ".", "+")
|
||||||
|
|
||||||
|
output, _ := base64.StdEncoding.DecodeString(in)
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64UrlEncode encodes the given input using the URL-safe base64 variant that the WireGuard Portal
|
||||||
|
// API expects. In comparison to the standard base64 encoding, it uses . instead of +, _ instead of /
|
||||||
|
// and - instead of =.
|
||||||
|
func Base64UrlEncode(in string) string {
|
||||||
|
out := base64.StdEncoding.EncodeToString([]byte(in))
|
||||||
|
out = strings.ReplaceAll(out, "+", ".")
|
||||||
|
out = strings.ReplaceAll(out, "/", "_")
|
||||||
|
out = strings.ReplaceAll(out, "=", "-")
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user