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 {AuthStoreType} from 'stores/auth-store';
import Timeout = NodeJS.Timeout;
import { WeightUnit } from 'src/api/main/submission';
import {Page, PaginationOptions, toQueryParams} from 'src/api/main/models';
@ -67,41 +66,6 @@ export enum UserFollowResponse {
}
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> {
const response = await api.post('/auth/register', payload);
return response.data;
@ -117,18 +81,9 @@ class AuthModule {
return response.data.token;
}
public async refreshToken(authStore: AuthStoreType) {
try {
const response = await api.get('/auth/token', authStore.axiosConfig);
authStore.token = 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 refreshToken(authStore: AuthStoreType): Promise<string> {
const response = await api.get('/auth/token', authStore.axiosConfig);
return response.data.token;
}
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-section>
</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-label>{{ $t('accountMenuItem.logOut') }}</q-item-label>
</q-item-section>
@ -42,7 +42,6 @@ account-related actions.
<script setup lang="ts">
import { useAuthStore } from 'stores/auth-store';
import api from 'src/api/main';
import { useRoute, useRouter } from 'vue-router';
import { getUserRoute } from 'src/router/user-routing';

View File

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

View File

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

View File

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

View File

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

View File

@ -83,7 +83,12 @@ const isOwnUser = ref(false);
// re-render.
watch(route, async (updatedRoute) => {
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)) {
await loadUser(userId);
}

View File

@ -6,8 +6,10 @@
*/
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 Timeout = NodeJS.Timeout;
interface AuthState {
/**
@ -20,18 +22,32 @@ interface AuthState {
*/
token: string | null;
tokenRefreshTimer: Timeout | null;
/**
* The list of roles that the currently authenticated user has.
*/
roles: string[];
}
const TOKEN_REFRESH_INTERVAL = 60 * 1000 * 25;
export const useAuthStore = defineStore('authStore', {
state: (): AuthState => {
return { user: null, token: null, roles: [] };
return { user: null, token: null, tokenRefreshTimer: null, roles: [] };
},
getters: {
/**
* Determines whether a user is currently logged in.
* @param state The store's state.
*/
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 {
if (this.token !== null) {
return {
@ -41,19 +57,80 @@ export const useAuthStore = defineStore('authStore', {
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,
},
actions: {
/**
* Logs a user into the application.
* @param user The user who was logged in.
* @param token The token that was obtained.
* @param roles The list of the user's roles.
* Attempts to log in with the given token credentials.
* @param credentials The credentials to use.
*/
logIn(user: User, token: string, roles: string[]) {
this.user = user;
this.token = token;
async logInWithCredentials(credentials: TokenCredentials) {
const token = await api.auth.getNewToken(credentials);
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;
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.
@ -61,7 +138,12 @@ export const useAuthStore = defineStore('authStore', {
logOut() {
this.user = null;
this.token = null;
if (this.tokenRefreshTimer) {
clearInterval(this.tokenRefreshTimer);
}
this.tokenRefreshTimer = null;
this.roles = [];
localStorage.removeItem('auth-token');
},
/**
* Updates the token that's stored for the currently authenticated user.
@ -69,6 +151,7 @@ export const useAuthStore = defineStore('authStore', {
*/
updateToken(token: string) {
this.token = token;
localStorage.setItem('auth-token', this.token);
}
}
});