Cleaned up authentication logic.

This commit is contained in:
Andrew Lalis 2023-03-29 11:37:03 +02:00
parent 4ec30b37f8
commit cde09d1b50
8 changed files with 129 additions and 91 deletions

View File

@ -1,6 +1,5 @@
import { api } from 'src/api/main/index'; import { api } from 'src/api/main/index';
import {AuthStoreType} from 'stores/auth-store'; import {AuthStoreType} from 'stores/auth-store';
import Timeout = NodeJS.Timeout;
import { WeightUnit } from 'src/api/main/submission'; import { WeightUnit } from 'src/api/main/submission';
import {Page, PaginationOptions, toQueryParams} from 'src/api/main/models'; import {Page, PaginationOptions, toQueryParams} from 'src/api/main/models';
@ -67,41 +66,6 @@ export enum UserFollowResponse {
} }
class AuthModule { class AuthModule {
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
private tokenRefreshTimer?: Timeout;
/**
* Attempts to use the given credentials to obtain an access token for
* sending authenticated requests.
* @param authStore The auth store to use to update app state.
* @param credentials The credentials for logging in.
*/
public async login(authStore: AuthStoreType, credentials: TokenCredentials) {
authStore.token = await this.getNewToken(credentials);
authStore.user = await this.getMyUser(authStore);
// Load the user's attached data right away too.
const [personalDetails, preferences, roles] = await Promise.all([
this.getMyPersonalDetails(authStore),
this.getMyPreferences(authStore),
this.getMyRoles(authStore)
]);
authStore.user.personalDetails = personalDetails;
authStore.user.preferences = preferences;
authStore.roles = roles;
clearTimeout(this.tokenRefreshTimer);
this.tokenRefreshTimer = setInterval(
() => this.refreshToken(authStore),
AuthModule.TOKEN_REFRESH_INTERVAL_MS
);
}
public logout(authStore: AuthStoreType) {
authStore.logOut();
clearTimeout(this.tokenRefreshTimer);
}
public async register(payload: UserCreationPayload): Promise<User> { public async register(payload: UserCreationPayload): Promise<User> {
const response = await api.post('/auth/register', payload); const response = await api.post('/auth/register', payload);
return response.data; return response.data;
@ -117,18 +81,9 @@ class AuthModule {
return response.data.token; return response.data.token;
} }
public async refreshToken(authStore: AuthStoreType) { public async refreshToken(authStore: AuthStoreType): Promise<string> {
try {
const response = await api.get('/auth/token', authStore.axiosConfig); const response = await api.get('/auth/token', authStore.axiosConfig);
authStore.token = response.data.token; return response.data.token;
} catch (error: any) {
authStore.logOut();
if (error.response) {
console.warn('Failed to refresh token: ', error.response);
} else {
console.error(error);
}
}
} }
public async getMyUser(authStore: AuthStoreType): Promise<User> { public async getMyUser(authStore: AuthStoreType): Promise<User> {

View File

@ -22,7 +22,7 @@ account-related actions.
<q-item-label>{{ $t('accountMenuItem.settings') }}</q-item-label> <q-item-label>{{ $t('accountMenuItem.settings') }}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item clickable v-close-popup @click="api.auth.logout(authStore)"> <q-item clickable v-close-popup @click="authStore.logOut()">
<q-item-section> <q-item-section>
<q-item-label>{{ $t('accountMenuItem.logOut') }}</q-item-label> <q-item-label>{{ $t('accountMenuItem.logOut') }}</q-item-label>
</q-item-section> </q-item-section>
@ -42,7 +42,6 @@ account-related actions.
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from 'stores/auth-store'; import { useAuthStore } from 'stores/auth-store';
import api from 'src/api/main';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { getUserRoute } from 'src/router/user-routing'; import { getUserRoute } from 'src/router/user-routing';

View File

@ -52,7 +52,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import {onMounted, ref} from 'vue';
import AccountMenuItem from 'components/AccountMenuItem.vue'; import AccountMenuItem from 'components/AccountMenuItem.vue';
import {useAuthStore} from 'stores/auth-store'; import {useAuthStore} from 'stores/auth-store';
@ -62,4 +62,8 @@ const leftDrawerOpen = ref(false);
function toggleLeftDrawer() { function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value; leftDrawerOpen.value = !leftDrawerOpen.value;
} }
onMounted(async () => {
await authStore.tryLogInWithStoredToken();
});
</script> </script>

View File

@ -1,3 +1,6 @@
/**
* Module declaration for the "leaflet" library for showing openstreetmap content.
*/
declare module 'leaflet'; declare module 'leaflet';
export {}; export {};

View File

@ -56,18 +56,16 @@
import StandardCenteredPage from 'components/StandardCenteredPage.vue'; import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import SlimForm from 'components/SlimForm.vue'; import SlimForm from 'components/SlimForm.vue';
import {ref} from 'vue'; import {ref} from 'vue';
import api from 'src/api/main';
import {useAuthStore} from 'stores/auth-store'; import {useAuthStore} from 'stores/auth-store';
import {useRoute, useRouter} from 'vue-router'; import {useRoute, useRouter} from 'vue-router';
import {useI18n} from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import {resolveLocale} from 'src/i18n'; import {resolveLocale} from 'src/i18n';
import { useQuasar } from 'quasar'; import {showApiErrorToast, showWarningToast} from 'src/utils';
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const i18n = useI18n({ useScope: 'global' }); const i18n = useI18n({ useScope: 'global' });
const quasar = useQuasar();
const loginModel = ref({ const loginModel = ref({
email: '', email: '',
@ -84,7 +82,7 @@ const passwordVisible = ref(false);
*/ */
async function tryLogin() { async function tryLogin() {
try { try {
await api.auth.login(authStore, loginModel.value); await authStore.logInWithCredentials(loginModel.value);
// Set the locale to the user's preferred locale. // Set the locale to the user's preferred locale.
i18n.locale.value = resolveLocale(authStore.user?.preferences?.locale).value; i18n.locale.value = resolveLocale(authStore.user?.preferences?.locale).value;
@ -96,18 +94,9 @@ async function tryLogin() {
await router.push(dest); await router.push(dest);
} catch (error: any) { } catch (error: any) {
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
quasar.notify({ showWarningToast('loginPage.authFailed');
message: i18n.t('loginPage.authFailed'),
type: 'warning',
position: 'top',
});
} else { } else {
quasar.notify({ showApiErrorToast(error);
message: i18n.t('generalErrors.apiError'),
type: 'negative',
position: 'top',
});
console.error(error);
} }
} }
} }

View File

@ -117,12 +117,8 @@ let initialPreferences: UserPreferences | null = null;
const newPassword = ref(''); const newPassword = ref('');
onMounted(async () => { onMounted(async () => {
// Redirect away from the page if the user isn't viewing their own settings.
const userId = route.params.userId as string; const userId = route.params.userId as string;
if (!authStore.user || authStore.user.id !== userId) { if (authStore.user && authStore.user.id === userId) {
await router.replace(`/users/${userId}`);
}
personalDetails.value = await api.auth.getMyPersonalDetails(authStore); personalDetails.value = await api.auth.getMyPersonalDetails(authStore);
initialPersonalDetails = structuredClone(toRaw(personalDetails.value)); initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
@ -130,6 +126,10 @@ onMounted(async () => {
initialPreferences = structuredClone(toRaw(preferences.value)); initialPreferences = structuredClone(toRaw(preferences.value));
newPassword.value = ''; newPassword.value = '';
} else {
// Redirect away from the page if the user isn't viewing their own settings.
await router.replace(`/users/${userId}`);
}
}); });
const personalDetailsChanged = computed(() => { const personalDetailsChanged = computed(() => {

View File

@ -83,7 +83,12 @@ const isOwnUser = ref(false);
// re-render. // re-render.
watch(route, async (updatedRoute) => { watch(route, async (updatedRoute) => {
if (updatedRoute.params.userId && updatedRoute.params.userId.length > 0) { if (updatedRoute.params.userId && updatedRoute.params.userId.length > 0) {
const userId = updatedRoute.params.userId[0]; let userId;
if (Array.isArray(updatedRoute.params.userId)) {
userId = updatedRoute.params.userId[0];
} else {
userId = updatedRoute.params.userId;
}
if (!profile.value || (profile.value.id !== userId)) { if (!profile.value || (profile.value.id !== userId)) {
await loadUser(userId); await loadUser(userId);
} }

View File

@ -6,8 +6,10 @@
*/ */
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { User } from 'src/api/main/auth'; import {TokenCredentials, User} from 'src/api/main/auth';
import api from 'src/api/main';
import {AxiosRequestConfig} from 'axios'; import {AxiosRequestConfig} from 'axios';
import Timeout = NodeJS.Timeout;
interface AuthState { interface AuthState {
/** /**
@ -20,18 +22,32 @@ interface AuthState {
*/ */
token: string | null; token: string | null;
tokenRefreshTimer: Timeout | null;
/** /**
* The list of roles that the currently authenticated user has. * The list of roles that the currently authenticated user has.
*/ */
roles: string[]; roles: string[];
} }
const TOKEN_REFRESH_INTERVAL = 60 * 1000 * 25;
export const useAuthStore = defineStore('authStore', { export const useAuthStore = defineStore('authStore', {
state: (): AuthState => { state: (): AuthState => {
return { user: null, token: null, roles: [] }; return { user: null, token: null, tokenRefreshTimer: null, roles: [] };
}, },
getters: { getters: {
/**
* Determines whether a user is currently logged in.
* @param state The store's state.
*/
loggedIn: (state) => state.user !== null && state.token !== null, loggedIn: (state) => state.user !== null && state.token !== null,
/**
* Gets the axios config that can be applied to requests to authenticate
* them as the current user. This will always return a valid request config
* even when the user is not logged in.
* @param state The store's state.
*/
axiosConfig(state): AxiosRequestConfig { axiosConfig(state): AxiosRequestConfig {
if (this.token !== null) { if (this.token !== null) {
return { return {
@ -41,19 +57,80 @@ export const useAuthStore = defineStore('authStore', {
return {}; return {};
} }
}, },
/**
* Getter that returns true if a user is authenticated, and the user is
* an admin, meaning they have special access to additional data.
* @param state The store's state.
*/
isAdmin: state => state.roles.indexOf('admin') !== -1, isAdmin: state => state.roles.indexOf('admin') !== -1,
}, },
actions: { actions: {
/** /**
* Logs a user into the application. * Attempts to log in with the given token credentials.
* @param user The user who was logged in. * @param credentials The credentials to use.
* @param token The token that was obtained.
* @param roles The list of the user's roles.
*/ */
logIn(user: User, token: string, roles: string[]) { async logInWithCredentials(credentials: TokenCredentials) {
this.user = user; const token = await api.auth.getNewToken(credentials);
this.token = token; await this._logInWithToken(token);
},
/**
* Attempts to log in with a token that's stored in the browser's local
* storage.
*/
async tryLogInWithStoredToken() {
const token = localStorage.getItem('auth-token');
if (token) {
this.token = token; // Temporarily set our token to the one that was stored, so we can use it to request a new one.
try {
await this.refreshToken();
if (this.token) { // If we were able to refresh the token, we can now go through with the login.
await this._logInWithToken(token);
}
} catch (error) {
console.warn('Could not log in with stored token: ', error);
this.logOut();
}
}
},
/**
* Initializes the auth state using a freshly-obtained token. This will
* populate all the necessary data to consider the user to be logged in,
* including fetching user data.
*
* Note: This method is intended to be used only internally.
* @param token The token to use.
*/
async _logInWithToken(token: string) {
this.updateToken(token);
this.user = await api.auth.getMyUser(this);
const [personalDetails, preferences, roles] = await Promise.all([
api.auth.getMyPersonalDetails(this),
api.auth.getMyPreferences(this),
api.auth.getMyRoles(this)
]);
this.user.personalDetails = personalDetails;
this.user.preferences = preferences;
this.roles = roles; this.roles = roles;
if (this.tokenRefreshTimer) {
clearInterval(this.tokenRefreshTimer);
}
this.tokenRefreshTimer = setInterval(
() => this.refreshToken(),
TOKEN_REFRESH_INTERVAL
);
},
/**
* Refreshes the existing token used for authentication. If this fails,
* the auth state will reset to the nominal logged-out state.
*/
async refreshToken() {
try {
const newToken = await api.auth.refreshToken(this);
this.updateToken(newToken);
} catch (error) {
console.error('Failed to refresh token: ', error);
this.logOut();
}
}, },
/** /**
* Logs a user out of the application, resetting the auth state. * Logs a user out of the application, resetting the auth state.
@ -61,7 +138,12 @@ export const useAuthStore = defineStore('authStore', {
logOut() { logOut() {
this.user = null; this.user = null;
this.token = null; this.token = null;
if (this.tokenRefreshTimer) {
clearInterval(this.tokenRefreshTimer);
}
this.tokenRefreshTimer = null;
this.roles = []; this.roles = [];
localStorage.removeItem('auth-token');
}, },
/** /**
* Updates the token that's stored for the currently authenticated user. * Updates the token that's stored for the currently authenticated user.
@ -69,6 +151,7 @@ export const useAuthStore = defineStore('authStore', {
*/ */
updateToken(token: string) { updateToken(token: string) {
this.token = token; this.token = token;
localStorage.setItem('auth-token', this.token);
} }
} }
}); });