Skip to content

Commit 2da205d

Browse files
committed
feat(fullstack): Logout page, persistent state
fix(auth): Automatic regeneration of refresh tokens
1 parent 39a1cc7 commit 2da205d

File tree

14 files changed

+205
-51
lines changed

14 files changed

+205
-51
lines changed

components/Layout.vue

+8-14
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
</template>
99

1010
<script setup lang="ts">
11-
import { useIntervalFn } from "@vueuse/core";
12-
1311
const props = defineProps<{
1412
title: string;
1513
description: string;
@@ -22,17 +20,13 @@ useHead({
2220
meta: [{ name: "description", content: description.value }],
2321
});
2422
25-
const userStore = useAuthStore();
26-
const { user } = storeToRefs(userStore);
23+
const interval = ref();
24+
25+
onMounted(() => {
26+
interval.value = refreshInterval();
27+
});
2728
28-
useIntervalFn(async () => {
29-
// eslint-disable-next-line no-useless-return
30-
if (!user.value.id) return;
31-
else {
32-
const { requestState } = await userStore.refresh(
33-
user.value.refreshToken,
34-
);
35-
if (requestState.error) throw requestState.error;
36-
}
37-
}, 300_000); // update every 5 minutes when logged in
29+
onBeforeUnmount(() => {
30+
window.clearTimeout(interval.value);
31+
});
3832
</script>

components/Nav.vue

+6-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
>
4141
</div>
4242
</li>
43+
<li v-if="user.id">
44+
<a href="/logout" class="md:p-4 py-2 block"
45+
>Logout</a
46+
>
47+
</li>
4348
<li v-if="!user.id">
4449
<a href="/register" class="md:p-4 py-2 block"
4550
>Register</a
@@ -70,7 +75,7 @@
7075
import { storeToRefs } from "pinia";
7176
import { ref } from "vue";
7277
73-
import { useAuthStore } from "~/utils/authStore";
78+
import { useAuthStore } from "~/store/auth";
7479
7580
const show = ref(false);
7681

middleware/auth.ts

+2-21
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,11 @@
1-
import jwt from "jsonwebtoken";
21
import { storeToRefs } from "pinia";
2+
import { useAuthStore } from "~/store/auth";
33

44
export default defineNuxtRouteMiddleware((to) => {
5-
const runtimeConfig = useRuntimeConfig();
65
const { user } = storeToRefs(useAuthStore());
76

8-
console.log(user.value);
9-
107
if (to.fullPath === "/login" && user.value.id) return navigateTo("/");
118

129
if (!user.value.id) return navigateTo("/login");
13-
else
14-
try {
15-
// Verify JWT access token
16-
const verificationPayload = jwt.verify(
17-
user.value.accessToken,
18-
runtimeConfig.jwtAccessSecret,
19-
);
20-
21-
if ((verificationPayload as jwt.JwtPayload).jti)
22-
return navigateTo(to.fullPath);
23-
else return navigateTo("/login");
24-
} catch (err) {
25-
throw createError({
26-
statusCode: 500,
27-
statusMessage: `Server error: ${(err as Error).name}`,
28-
});
29-
}
10+
else return navigateTo(to.fullPath);
3011
});

nuxt.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ export default defineNuxtConfig({
1616
},
1717

1818
css: ['@wale/general-sans', '~/assets/css/main.css'],
19-
modules: ["@nuxt/image", "@pinia/nuxt"]
19+
modules: ["@nuxt/image", "@pinia/nuxt", "@pinia-plugin-persistedstate/nuxt"]
2020
});

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"db:format": "prisma format"
1717
},
1818
"dependencies": {
19+
"@pinia-plugin-persistedstate/nuxt": "^1.2.0",
1920
"@pinia/nuxt": "^0.5.1",
2021
"@prisma/client": "5.9.1",
2122
"@wale/general-sans": "^1.0.0",

pages/login.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
<script setup lang="ts">
8585
import { ref } from "vue";
8686
87-
import { useAuthStore } from "~/utils/authStore";
87+
import { useAuthStore } from "~/store/auth";
8888
8989
const userStore = useAuthStore();
9090

pages/logout.vue

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<template>
2+
<Layout
3+
title="Logout | Readconquista"
4+
description="Logout of Readconquista"
5+
>
6+
<div
7+
class="flex h-full items-center justify-center flex-col gap-4 md:gap-8"
8+
>
9+
<h2 class="text-3xl md:text-5xl font-black">Logout</h2>
10+
<h4 class="text-xl md:text-2xl">
11+
Are you sure you want to log out?
12+
</h4>
13+
<div class="flex flex-row gap-8">
14+
<button
15+
type="button"
16+
class="p-4 rounded-xl bg-grayscale-800 text-grayscale-400"
17+
@click="logout()"
18+
>
19+
Yes
20+
</button>
21+
<button
22+
type="button"
23+
class="p-4 rounded-xl bg-grayscale-400 text-grayscale-800"
24+
@click="cancelLogout()"
25+
>
26+
No
27+
</button>
28+
</div>
29+
<div v-if="error" class="mt-2 flex flex-row justify-center">
30+
<span class="text-red-500 font-bold"
31+
>{{ error.statusCode }} - {{ error.statusMessage }}</span
32+
>
33+
</div>
34+
</div>
35+
</Layout>
36+
</template>
37+
38+
<script setup lang="ts">
39+
import { useAuthStore } from "~/store/auth";
40+
41+
const router = useRouter();
42+
const authStore = useAuthStore();
43+
44+
const error = ref();
45+
46+
const logout = async () => {
47+
const { requestState } = await authStore.logout();
48+
49+
if (requestState.error) error.value = requestState.error;
50+
else await router.push("/");
51+
};
52+
53+
const cancelLogout = async () => {
54+
await router.push("/");
55+
};
56+
</script>

server/api/auth/logout.post.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default defineEventHandler(async (event) => {
2+
const { user } = await protectRoute(event);
3+
4+
revokeTokensByIdentifier({ username: user.username })
5+
.then(() => setResponseStatus(event, 200, "Successfully logged out"))
6+
.catch((err) =>
7+
createError({
8+
statusCode: 500,
9+
statusMessage: `${(err as Error).message}`,
10+
}),
11+
);
12+
});

server/api/auth/refresh.post.ts

-6
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,6 @@ export default defineEventHandler(async (event) => {
2121
runtimeConfig.jwtRefreshSecret,
2222
) as jwt.JwtPayload;
2323

24-
if (new Date().getTime() > payload.exp!)
25-
throw createError({
26-
statusCode: 403,
27-
statusMessage: "Forbidden: token is still valid",
28-
});
29-
3024
const savedRefreshToken = await findRefreshTokenById(payload.jti!);
3125

3226
if (!savedRefreshToken || savedRefreshToken.revoked)

server/utils/jwt.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function generateRefreshToken(user: User, jti: string) {
2323
},
2424
runtimeConfig.jwtRefreshSecret,
2525
{
26-
expiresIn: "8h",
26+
expiresIn: "24h",
2727
},
2828
);
2929
}

utils/authStore.ts store/auth.ts

+22-3
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,28 @@ export const useAuthStore = defineStore("auth", {
7474
return { data: {} as UserState, requestState };
7575
},
7676

77-
async logout() {
78-
await revokeTokensByIdentifier({ email: this.user.email });
77+
async logout(): Promise<{ requestState: RequestState }> {
78+
const requestState: RequestState = {
79+
loading: true,
80+
error: null,
81+
};
7982

80-
this.user = {} as UserState;
83+
try {
84+
await $fetch("/api/auth/logout", {
85+
method: "post",
86+
headers: {
87+
"Content-Type": "application/json",
88+
Authorization: `Bearer ${this.user.accessToken}`,
89+
},
90+
});
91+
requestState.loading = false;
92+
this.user = {} as UserState;
93+
return { requestState };
94+
} catch (error) {
95+
requestState.loading = false;
96+
requestState.error = error as Error;
97+
return { requestState };
98+
}
8199
},
82100

83101
async register(
@@ -137,4 +155,5 @@ export const useAuthStore = defineStore("auth", {
137155
}
138156
},
139157
},
158+
persist: true,
140159
});

utils/pinia.ts

-3
This file was deleted.

utils/refreshInterval.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useAuthStore } from "~/store/auth";
2+
3+
export default function refreshInterval(): number | null {
4+
const authStore = useAuthStore();
5+
6+
if (authStore.user.accessToken) {
7+
const [, jwtBase64] = authStore.user.accessToken.split(".");
8+
const jwtToken = JSON.parse(atob(jwtBase64));
9+
10+
const expires = new Date(jwtToken.exp * 1000);
11+
const timeout = expires.getTime() - Date.now() - 60 * 1000;
12+
13+
return window.setTimeout(async () => {
14+
await authStore.refresh(authStore.user.refreshToken);
15+
}, timeout);
16+
} else return null;
17+
}

yarn.lock

+78
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,30 @@
11681168
unimport "^3.7.1"
11691169
untyped "^1.4.2"
11701170

1171+
"@nuxt/kit@^3.8.0":
1172+
version "3.11.1"
1173+
resolved "https://registry.yarnpkg.com/@nuxt/kit/-/kit-3.11.1.tgz#342335f1cbf7422a3e65be67f3ff975e6075decf"
1174+
integrity sha512-8VVlhaY4N+wipgHmSXP+gLM+esms9TEBz13I/J++PbOUJuf2cJlUUTyqMoRVL0xudVKK/8fJgSndRkyidy1m2w==
1175+
dependencies:
1176+
"@nuxt/schema" "3.11.1"
1177+
c12 "^1.10.0"
1178+
consola "^3.2.3"
1179+
defu "^6.1.4"
1180+
globby "^14.0.1"
1181+
hash-sum "^2.0.0"
1182+
ignore "^5.3.1"
1183+
jiti "^1.21.0"
1184+
knitwork "^1.0.0"
1185+
mlly "^1.6.1"
1186+
pathe "^1.1.2"
1187+
pkg-types "^1.0.3"
1188+
scule "^1.3.0"
1189+
semver "^7.6.0"
1190+
ufo "^1.5.2"
1191+
unctx "^2.3.1"
1192+
unimport "^3.7.1"
1193+
untyped "^1.4.2"
1194+
11711195
"@nuxt/[email protected]", "@nuxt/schema@^3.9.1":
11721196
version "3.10.3"
11731197
resolved "https://registry.yarnpkg.com/@nuxt/schema/-/schema-3.10.3.tgz#b9bdcced298b64f280f12936e518fe4f32c90328"
@@ -1185,6 +1209,23 @@
11851209
unimport "^3.7.1"
11861210
untyped "^1.4.2"
11871211

1212+
1213+
version "3.11.1"
1214+
resolved "https://registry.yarnpkg.com/@nuxt/schema/-/schema-3.11.1.tgz#1f9e59be77d8c08904c06a26d9570c9c687bcfd6"
1215+
integrity sha512-XyGlJsf3DtkouBCvBHlvjz+xvN4vza3W7pY3YBNMnktxlMQtfFiF3aB3A2NGLmBnJPqD3oY0j7lljraELb5hkg==
1216+
dependencies:
1217+
"@nuxt/ui-templates" "^1.3.1"
1218+
consola "^3.2.3"
1219+
defu "^6.1.4"
1220+
hookable "^5.5.3"
1221+
pathe "^1.1.2"
1222+
pkg-types "^1.0.3"
1223+
scule "^1.3.0"
1224+
std-env "^3.7.0"
1225+
ufo "^1.5.2"
1226+
unimport "^3.7.1"
1227+
untyped "^1.4.2"
1228+
11881229
"@nuxt/telemetry@^2.5.3":
11891230
version "2.5.3"
11901231
resolved "https://registry.yarnpkg.com/@nuxt/telemetry/-/telemetry-2.5.3.tgz#e702bbccfb5cc4ab9b0cfc8239e96ed9e2ccfc74"
@@ -1350,6 +1391,15 @@
13501391
resolved "https://registry.yarnpkg.com/@phc/format/-/format-1.0.0.tgz#b5627003b3216dc4362125b13f48a4daa76680e4"
13511392
integrity sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==
13521393

1394+
"@pinia-plugin-persistedstate/nuxt@^1.2.0":
1395+
version "1.2.0"
1396+
resolved "https://registry.yarnpkg.com/@pinia-plugin-persistedstate/nuxt/-/nuxt-1.2.0.tgz#559d6abf0204726fa1fb7bb324ea4be99a10e6bd"
1397+
integrity sha512-2rtgx5viGSMQMCoFYZMHguA2FhFKCUvw0PwETfqQegsWeBHlqk1/D0G/9xqep8Hq+c1BuFx+jNLJzoLXtYfivg==
1398+
dependencies:
1399+
"@nuxt/kit" "^3.8.0"
1400+
defu "^6.1.2"
1401+
pinia-plugin-persistedstate ">=3.2.0"
1402+
13531403
"@pinia/nuxt@^0.5.1":
13541404
version "0.5.1"
13551405
resolved "https://registry.yarnpkg.com/@pinia/nuxt/-/nuxt-0.5.1.tgz#ee7c979d365a5dfda882430ddfae405fbd78d8d5"
@@ -2571,6 +2621,24 @@ bundle-name@^4.1.0:
25712621
dependencies:
25722622
run-applescript "^7.0.0"
25732623

2624+
c12@^1.10.0:
2625+
version "1.10.0"
2626+
resolved "https://registry.yarnpkg.com/c12/-/c12-1.10.0.tgz#e1936baa26fd03a9427875554aa6aeb86077b7fb"
2627+
integrity sha512-0SsG7UDhoRWcuSvKWHaXmu5uNjDCDN3nkQLRL4Q42IlFy+ze58FcCoI3uPwINXinkz7ZinbhEgyzYFw9u9ZV8g==
2628+
dependencies:
2629+
chokidar "^3.6.0"
2630+
confbox "^0.1.3"
2631+
defu "^6.1.4"
2632+
dotenv "^16.4.5"
2633+
giget "^1.2.1"
2634+
jiti "^1.21.0"
2635+
mlly "^1.6.1"
2636+
ohash "^1.1.3"
2637+
pathe "^1.1.2"
2638+
perfect-debounce "^1.0.0"
2639+
pkg-types "^1.0.3"
2640+
rc9 "^2.1.1"
2641+
25742642
c12@^1.9.0:
25752643
version "1.9.0"
25762644
resolved "https://registry.yarnpkg.com/c12/-/c12-1.9.0.tgz#a98b3d16b5010667983df70794a332d6b9865ec3"
@@ -6103,6 +6171,11 @@ pify@^2.3.0:
61036171
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
61046172
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
61056173

6174+
pinia-plugin-persistedstate@>=3.2.0:
6175+
version "3.2.1"
6176+
resolved "https://registry.yarnpkg.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.1.tgz#66780602aecd6c7b152dd7e3ddc249a1f7a13fe5"
6177+
integrity sha512-MK++8LRUsGF7r45PjBFES82ISnPzyO6IZx3CH5vyPseFLZCk1g2kgx6l/nW8pEBKxxd4do0P6bJw+mUSZIEZUQ==
6178+
61066179
pinia@>=2.1.7, pinia@^2.1.7:
61076180
version "2.1.7"
61086181
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.1.7.tgz#4cf5420d9324ca00b7b4984d3fbf693222115bbc"
@@ -7512,6 +7585,11 @@ ufo@^1.1.2, ufo@^1.2.0, ufo@^1.3.0, ufo@^1.3.1, ufo@^1.3.2, ufo@^1.4.0:
75127585
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.4.0.tgz#39845b31be81b4f319ab1d99fd20c56cac528d32"
75137586
integrity sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==
75147587

7588+
ufo@^1.5.2:
7589+
version "1.5.3"
7590+
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.3.tgz#3325bd3c977b6c6cd3160bf4ff52989adc9d3344"
7591+
integrity sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==
7592+
75157593
ultrahtml@^1.5.3:
75167594
version "1.5.3"
75177595
resolved "https://registry.yarnpkg.com/ultrahtml/-/ultrahtml-1.5.3.tgz#e7a903a4b28a0e49b71b0801b444050bb0a369c7"

0 commit comments

Comments
 (0)