feat: add safe redirects from login pages

This commit is contained in:
mrjvs
2026-08-15 15:21:24 +02:00
parent 7b665bdfe8
commit a8f217b652
7 changed files with 92 additions and 18 deletions

View File

@@ -4,6 +4,7 @@
PN_WEBSITE_PUBLIC_BASE_URL=http://localhost:3000
PN_WEBSITE_PUBLIC_CDN_BASE_URL=http://pretendo.localhost:3902
PN_WEBSITE_PUBLIC_COOKIE_SECURE=false
PN_WEBSITE_PUBLIC_REDIRECT_HOSTS=localhost:3000
# Optional - Authentication
PN_WEBSITE_GRPC_HOST=localhost:8123

View File

@@ -65,6 +65,7 @@ export default defineNuxtConfig({
public: {
baseUrl: 'https://pretendo.network',
cdnBaseUrl: 'https://r2-cdn.pretendo.cc',
redirectHosts: 'pretendo.network',
hcaptchaSiteKey: '',
cookieSecure: true
}

View File

@@ -3,7 +3,7 @@ import { Popover } from 'reka-ui/namespaced';
const { locales, setLocale } = useI18n();
const me = useMeStore();
const authStore = useAuthStore();
const authUtils = useAuthUtils();
const user = computed(() => me.user);
const openDropdown = ref<boolean | string>(false);
@@ -522,7 +522,7 @@ onMounted(() => {
</a>
<button
class="button logout"
@click="authStore.logout()"
@click="authUtils.logout()"
>
{{ $t("nav.accountWidget.logout") }}
</button>

View File

@@ -0,0 +1,62 @@
export function getSafeRedirectUrl(input: string | null, baseUrl: string, allowedHosts: string[] = []): { url: string; external: boolean } | null {
const parsedBaseUrl = new URL(baseUrl);
if (!input) {
return null; // No or empty input
}
let url: URL | null = null;
try {
url = new URL(input, parsedBaseUrl);
} catch {
return null; // Invalid URL input
}
const isAllowedHost = allowedHosts.includes(url.host);
if (!isAllowedHost) {
return null; // Not in the allowed hosts
}
// If on the same host as the website, only return the path. For internal redirects
if (url.host === parsedBaseUrl.host) {
return {
url: url.pathname + url.search,
external: false
};
}
return {
url: url.toString(),
external: true
};
}
export function useAuthUtils() {
const authStore = useAuthStore();
const route = useRoute();
const config = useRuntimeConfig();
const allowedRedirectHosts = computed(() => config.public.redirectHosts.split(' ').map(v => v.trim()).filter(v => v.length > 0));
return {
async safelyRedirectAfterLogin(inputUrl: string | null) {
const redirectUrl = getSafeRedirectUrl(inputUrl, config.public.baseUrl, allowedRedirectHosts.value);
if (!redirectUrl) {
await navigateTo('/account');
return;
}
await navigateTo(redirectUrl.url, {
external: redirectUrl.external
});
},
async redirectToLogin() {
await navigateTo({
path: '/account/login',
query: {
redirect: route.fullPath
}
});
},
async logout() {
authStore.logout();
await navigateTo('/'); // Back to homepage
}
};
}

View File

@@ -1,8 +1,17 @@
<script setup lang="ts">
const route = useRoute();
const auth = useAuthStore();
const redirect = computed(() => route.query.redirect);
const registerURI = computed(() => `/account/register${redirect.value ? `?redirect=${redirect.value}` : ''}`);
const authUtils = useAuthUtils();
const redirect = computed(() => route.query.redirect?.toString() ?? null);
const registerURI = computed(() => {
if (redirect.value) {
const params = new URLSearchParams({
redirect: redirect.value
});
return `/account/register?${params}`;
}
return `/account/register`;
});
const loginForm = reactive({ username: '', password: '' });
const errorMessage = ref<string | null>();
@@ -20,12 +29,7 @@ async function loginSubmission() {
accessToken: res.accessToken,
refreshToken: res.refreshToken
});
if (typeof redirect.value === 'string') {
await navigateTo(redirect.value, { external: true });
} else {
await navigateTo('/account');
}
await authUtils.safelyRedirectAfterLogin(redirect.value);
} catch (error: unknown) {
const err = getApiError(error);
errorMessage.value = err.code;

View File

@@ -4,8 +4,17 @@ import type { ApiAuthRegisterRequest } from '~~/shared/api-types';
const route = useRoute();
const auth = useAuthStore();
const redirect = computed(() => route.query.redirect);
const loginURI = computed(() => `/account/login${redirect.value ? `?redirect=${redirect.value}` : ''}`);
const authUtils = useAuthUtils();
const redirect = computed(() => route.query.redirect?.toString() ?? null);
const loginURI = computed(() => {
if (redirect.value) {
const params = new URLSearchParams({
redirect: redirect.value
});
return `/account/login?${params}`;
}
return `/account/login`;
});
const registerForm = reactive({ email: '', username: '', mii_name: '', password: '', password_confirm: '' });
@@ -30,12 +39,7 @@ async function registerSubmission() {
accessToken: res.accessToken,
refreshToken: res.refreshToken
});
if (typeof redirect.value === 'string') {
await navigateTo(redirect.value, { external: true });
} else {
await navigateTo('/account');
}
await authUtils.safelyRedirectAfterLogin(redirect.value);
} catch (error: unknown) {
if (error === 'challenge-closed') {
// Thrown if the captcha is closed, can be safely ignored

View File

@@ -42,6 +42,8 @@ declare module 'nuxt/schema' {
interface PublicRuntimeConfig {
baseUrl: string;
cdnBaseUrl: string;
redirectHosts: string;
cookieSecure: boolean;
hcaptchaSiteKey: string;
}