fix: cookie-auth-issues (#365)

* fix session clearing on error

* use singleton context to manage user state

* implement remember-me functionality

* fix errors

* fix more errors
This commit is contained in:
Hayden 2023-03-22 21:52:25 -08:00 committed by GitHub
parent ed1230e17d
commit faed343eda
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 175 additions and 89 deletions

View file

@ -1,9 +1,9 @@
<template>
<div class="flex">
<div class="relative">
<FormTextField v-model="value" placeholder="Password" label="Password" :type="inputType"> </FormTextField>
<button
type="button"
class="inline-flex p-1 ml-1 justify-center mt-auto mb-3 tooltip"
class="inline-flex p-1 ml-1 justify-center mt-auto mb-3 tooltip absolute top-11 right-3"
data-tip="Toggle Password Show"
@click="toggle()"
>

View file

@ -34,6 +34,7 @@ export function useUserApi(): UserClient {
requests.addResponseInterceptor(logger);
requests.addResponseInterceptor(r => {
if (r.status === 401) {
console.error("unauthorized request, invalidating session");
authCtx.invalidateSession();
}
});

View file

@ -4,7 +4,6 @@ import { UserOut } from "~~/lib/api/types/data-contracts";
import { UserClient } from "~~/lib/api/user";
export interface IAuthContext {
self?: UserOut;
get token(): string | null;
get expiresAt(): string | null;
get attachmentToken(): string | null;
@ -37,10 +36,17 @@ export interface IAuthContext {
/**
* Logs in the user and sets the authorization context via cookies
*/
login(api: PublicApi, email: string, password: string): ReturnType<PublicApi["login"]>;
login(api: PublicApi, email: string, password: string, stayLoggedIn: boolean): ReturnType<PublicApi["login"]>;
}
class AuthContext implements IAuthContext {
// eslint-disable-next-line no-use-before-define
private static _instance?: AuthContext;
private static readonly cookieTokenKey = "hb.auth.token";
private static readonly cookieExpiresAtKey = "hb.auth.expires_at";
private static readonly cookieAttachmentTokenKey = "hb.auth.attachment_token";
user?: UserOut;
private _token: CookieRef<string | null>;
private _expiresAt: CookieRef<string | null>;
@ -58,14 +64,22 @@ class AuthContext implements IAuthContext {
return this._attachmentToken.value;
}
constructor(
token: CookieRef<string | null>,
expiresAt: CookieRef<string | null>,
attachmentToken: CookieRef<string | null>
) {
this._token = token;
this._expiresAt = expiresAt;
this._attachmentToken = attachmentToken;
private constructor(token: string, expiresAt: string, attachmentToken: string) {
this._token = useCookie(token);
this._expiresAt = useCookie(expiresAt);
this._attachmentToken = useCookie(attachmentToken);
}
static get instance() {
if (!this._instance) {
this._instance = new AuthContext(
AuthContext.cookieTokenKey,
AuthContext.cookieExpiresAtKey,
AuthContext.cookieAttachmentTokenKey
);
}
return this._instance;
}
isExpired() {
@ -81,29 +95,28 @@ class AuthContext implements IAuthContext {
}
isAuthorized() {
return this._token.value !== null && !this.isExpired();
return !!this._token.value && !this.isExpired();
}
invalidateSession() {
this.user = undefined;
// Delete the cookies
this._token.value = null;
this._expiresAt.value = null;
this._attachmentToken.value = null;
navigateTo("/");
console.log("Session invalidated");
}
async login(api: PublicApi, email: string, password: string) {
const r = await api.login(email, password);
async login(api: PublicApi, email: string, password: string, stayLoggedIn: boolean) {
const r = await api.login(email, password, stayLoggedIn);
if (!r.error) {
this._token.value = r.data.token;
this._expiresAt.value = r.data.expiresAt as string;
this._attachmentToken.value = r.data.attachmentToken;
console.log({
token: this._token.value,
expiresAt: this._expiresAt.value,
attachmentToken: this._attachmentToken.value,
});
}
return r;
@ -121,9 +134,5 @@ class AuthContext implements IAuthContext {
}
export function useAuthContext(): IAuthContext {
const tokenCookie = useCookie("hb.auth.token");
const expiresAtCookie = useCookie("hb.auth.expires_at");
const attachmentTokenCookie = useCookie("hb.auth.attachment_token");
return new AuthContext(tokenCookie, expiresAtCookie, attachmentTokenCookie);
return AuthContext.instance;
}

View file

@ -94,7 +94,7 @@
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
const username = computed(() => authCtx.self?.name || "User");
const username = computed(() => authCtx.user?.name || "User");
// Preload currency format
useFormatCurrency();
@ -226,11 +226,6 @@
const api = useUserApi();
async function logout() {
const { error } = await authCtx.logout(api);
if (error) {
return;
}
navigateTo("/");
await authCtx.logout(api);
}
</script>

View file

@ -1,10 +1,5 @@
import { BaseAPI, route } from "./base";
import { ApiSummary, TokenResponse, UserRegistration } from "./types/data-contracts";
export type LoginPayload = {
username: string;
password: string;
};
import { ApiSummary, LoginForm, TokenResponse, UserRegistration } from "./types/data-contracts";
export type StatusResult = {
health: boolean;
@ -18,12 +13,13 @@ export class PublicApi extends BaseAPI {
return this.http.get<ApiSummary>({ url: route("/status") });
}
public login(username: string, password: string) {
return this.http.post<LoginPayload, TokenResponse>({
public login(username: string, password: string, stayLoggedIn = false) {
return this.http.post<LoginForm, TokenResponse>({
url: route("/users/login"),
body: {
username,
password,
stayLoggedIn,
},
});
}

View file

@ -412,6 +412,12 @@ export interface ItemAttachmentToken {
token: string;
}
export interface LoginForm {
password: string;
stayLoggedIn: boolean;
username: string;
}
export interface TokenResponse {
attachmentToken: string;
expiresAt: Date | string;

View file

@ -39,19 +39,4 @@ export class UserClient extends BaseAPI {
Object.freeze(this);
}
/** @deprecated use this.user.self() */
public self() {
return this.user.self();
}
/** @deprecated use this.user.logout() */
public logout() {
return this.user.logout();
}
/** @deprecated use this.user.delete() */
public deleteAccount() {
return this.user.delete();
}
}

View file

@ -2,10 +2,14 @@ export default defineNuxtRouteMiddleware(async () => {
const ctx = useAuthContext();
const api = useUserApi();
if (!ctx.isAuthorized()) {
return navigateTo("/");
}
if (!ctx.user) {
const { data, error } = await api.user.self();
if (error) {
navigateTo("/");
return navigateTo("/");
}
ctx.user = data.item;

View file

@ -40,6 +40,7 @@
const email = ref("");
const password = ref("");
const canRegister = ref(false);
const remember = ref(false);
const groupToken = computed<string>({
get() {
@ -91,7 +92,7 @@
async function login() {
loading.value = true;
const { error } = await ctx.login(api, email.value, loginPassword.value);
const { error } = await ctx.login(api, email.value, loginPassword.value, remember.value);
if (error) {
toast.error("Invalid email or password");
@ -196,8 +197,16 @@
</template>
<FormTextField v-model="email" label="Email" />
<FormPassword v-model="loginPassword" label="Password" />
<div class="card-actions justify-end mt-2">
<button type="submit" class="btn btn-primary" :class="loading ? 'loading' : ''" :disabled="loading">
<div class="max-w-[140px]">
<FormCheckbox v-model="remember" label="Remember Me" />
</div>
<div class="card-actions justify-end">
<button
type="submit"
class="btn btn-primary btn-block"
:class="loading ? 'loading' : ''"
:disabled="loading"
>
Login
</button>
</div>

View file

@ -82,14 +82,15 @@
const auth = useAuthContext();
const details = computed(() => {
console.log(auth.user);
return [
{
name: "Name",
text: auth.self?.name || "Unknown",
text: auth.user?.name || "Unknown",
},
{
name: "Email",
text: auth.self?.email || "Unknown",
text: auth.user?.email || "Unknown",
},
] as Detail[];
});