Browse Source

Add Vuex. Transfer Auth to store. Remove logging. Add remember me functionality

master
David Ludwig 4 years ago
parent
commit
98e60d3153
20 changed files with 406 additions and 165 deletions
  1. +1
    -0
      package.json
  2. +3
    -2
      src/app/App.vue
  3. +0
    -111
      src/app/auth.ts
  4. +17
    -1
      src/app/components/CheckBox.vue
  5. +1
    -1
      src/app/components/MovieModal.vue
  6. +1
    -2
      src/app/components/TextBox.vue
  7. +2
    -0
      src/app/index.ts
  8. +30
    -4
      src/app/routes/index.ts
  9. +9
    -0
      src/app/shims-vuex.d.ts
  10. +122
    -0
      src/app/store/actions.ts
  11. +36
    -0
      src/app/store/generics.ts
  12. +31
    -0
      src/app/store/getters.ts
  13. +36
    -0
      src/app/store/index.ts
  14. +69
    -0
      src/app/store/mutations.ts
  15. +6
    -0
      src/app/store/schema.ts
  16. +15
    -0
      src/app/store/state.ts
  17. +2
    -3
      src/app/views/Home.vue
  18. +20
    -34
      src/app/views/Login.vue
  19. +0
    -2
      src/server/services/WebServer/middleware/auth.ts
  20. +5
    -5
      yarn.lock

+ 1
- 0
package.json View File

@ -34,6 +34,7 @@
"vue": "^3.0.5",
"vue-router": "^4.0.6",
"vuedraggable": "^4.0.1",
"vuex": "^4.0.0",
"websocket": "^1.0.33"
},
"devDependencies": {


+ 3
- 2
src/app/App.vue View File

@ -6,11 +6,12 @@
<script lang="ts">
import { defineComponent } from 'vue';
import * as auth from "./auth";
import { Action, useStore } from "./store";
export default defineComponent({
setup() {
auth.loadToken();
const store = useStore();
store.dispatch(Action.AuthLoad, undefined);
}
});
</script>

+ 0
- 111
src/app/auth.ts View File

@ -1,111 +0,0 @@
import jwtDecode from "jwt-decode";
import router from "./routes";
export interface IUser {
id: number,
name: string,
isAdmin: boolean
}
/**
* The active JWT
*/
let token: string | null;
/**
* The decoded user object
*/
let user: IUser | null;
/**
* Check if the user is an admin
*/
export function isAdmin() {
return user && user.isAdmin;
}
/**
* Check if the client is authenticated
*/
export function isAuthenticated() {
return Boolean(token);
}
/**
* Get the logged in user (assumes authentication has been checked)
*/
export function getUser() {
return <IUser>user;
}
/**
* Load the token from local storage
*/
export function loadToken() {
try {
token = localStorage.getItem("jwt");
user = jwtDecode(<string>token);
} catch(e) {
console.log("Failed to load token");
token = null;
user = null;
return false;
}
console.log("Token loaded", token);
return true;
}
/**
* Delete the token from local storage
*/
export function forgetToken() {
token = null;
user = null;
localStorage.removeItem("jwt");
}
/**
* Store a JWT token in local storage
*/
export function storeToken(jwtToken: string) {
try {
user = jwtDecode(jwtToken);
token = jwtToken;
localStorage.setItem("jwt", jwtToken);
} catch(e) {
user = null;
token = null;
return false;
}
return true;
}
/**
* Fetch request providing authentication and logout upon unauthorized requests
*/
export async function authFetch(path: string, options: RequestInit = {}): Promise<Response> {
console.log("Performing auth fetch");
options.credentials = "include";
options.headers = Object.assign(options.headers ?? {}, {
"Authorization": `Bearer ${token}`
});
let response = await fetch(path, options);
if (response.status === 401) {
// forgetToken();
console.log("Forgetting token...");
router.push({ name: "Login" });
throw Error("Unauthorized");
}
return response;
}
/**
* @TODO Remove later
*/
(<any>window).forgetToken = forgetToken;
(<any>window).authFetch = authFetch;
/**
* Ensure the token is loaded upon startup
*/
loadToken();

+ 17
- 1
src/app/components/CheckBox.vue View File

@ -1,6 +1,6 @@
<template>
<label class="checkbox-label relative flex items-center">
<input type="checkbox" :disabled="disabled">
<input type="checkbox" v-model="isChecked" :disabled="disabled">
<span></span>
<span v-if="label" class="ml-2">{{label}}</span>
</label>
@ -10,6 +10,11 @@
import { defineComponent } from "vue";
export default defineComponent({
data() {
return {
isChecked: this.modelValue
}
},
props: {
disabled: {
type: Boolean,
@ -18,6 +23,16 @@ export default defineComponent({
label: {
type: String,
required: true
},
modelValue: {
type: Boolean,
default: false
}
},
watch: {
isChecked(newState) {
this.$emit("update:modelValue", newState);
this.$emit("onChange", newState);
}
}
});
@ -26,6 +41,7 @@ export default defineComponent({
<style>
.checkbox-label input[type="checkbox"] {
display: none;
width: 0;
height: 0;
}


+ 1
- 1
src/app/components/MovieModal.vue View File

@ -46,7 +46,7 @@
import { defineComponent } from "vue";
import { IApiDataResponse } from "@common/api_schema";
import { IMovieDetails } from "@lib/tmdb/schema";
import { authFetch } from "../auth";
import { authFetch } from "../routes";
import { getAverageRgb } from "../util";
type MovieDetails = IApiDataResponse<IMovieDetails>;


+ 1
- 2
src/app/components/TextBox.vue View File

@ -43,8 +43,7 @@ export default defineComponent({
* Invoked when the value of the input has changed
*/
onChange(event: Event) {
let value: string = (<any>event.target).value;
this.$emit("update:modelValue", value);
this.$emit("update:modelValue", this.value);
this.$emit("onChange", event);
},


+ 2
- 0
src/app/index.ts View File

@ -1,10 +1,12 @@
import { createApp } from 'vue'
import router from "./routes";
import store from "./store";
import App from './App.vue'
import "./styles/index.css";
let app = createApp(App);
app.use(router);
app.use(store);
app.mount("#app");
/**


+ 30
- 4
src/app/routes/index.ts View File

@ -1,11 +1,11 @@
import { createRouter, createWebHistory, NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router";
import * as auth from "../auth";
import store, { Action } from "../store";
/**
* Check if the user is a guest; redirect otherwise
*/
function requiresGuest(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
if (auth.isAuthenticated()) {
if (store.getters.isAuthenticated) {
next({ name: "Home" });
return;
}
@ -16,13 +16,39 @@ function requiresGuest(to: RouteLocationNormalized, from: RouteLocationNormalize
* Check if the user is authenticated; redirect otherwise
*/
function requiresAuth(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
if (!auth.isAuthenticated()) {
if (!store.getters.isAuthenticated) {
next({ name: "Login" });
return;
}
next();
}
/**
* Fetch request providing authentication and logout upon unauthorized requests
*/
export async function authFetch(path: string, options: RequestInit = {}): Promise<Response> {
// Ensure the user is logged in
let token = store.getters.token;
if (token == null) {
router.push({ name: "Login" });
throw Error("Unauthorized");
}
// Perform the request
options.credentials = "include";
options.headers = Object.assign(options.headers ?? {}, {
"Authorization": `Bearer ${token}`
});
let response = await fetch(path, options);
// If an unauthorized gets thrown, forget the user and redirect to login
if (response.status === 401) {
store.dispatch(Action.AuthForget, undefined);
router.push({ name: "Login" });
throw Error("Unauthorized");
}
return response;
}
const routes: RouteRecordRaw[] = [
{
path: "/",
@ -47,7 +73,7 @@ const routes: RouteRecordRaw[] = [
name: "Logout",
component: {
beforeRouteEnter(to, from, next) {
auth.forgetToken();
store.dispatch(Action.AuthForget, undefined);
next({ name: "Login" });
}
}


+ 9
- 0
src/app/shims-vuex.d.ts View File

@ -0,0 +1,9 @@
import { ComponentCustomProperties } from 'vue'
import type { Store } from "./store";
declare module '@vue/runtime-core' {
// provide typings for `this.$store`
interface ComponentCustomProperties {
$store: Store
}
}

+ 122
- 0
src/app/store/actions.ts View File

@ -0,0 +1,122 @@
import { ActionTree } from "vuex";
import { Actions } from "./generics";
import { IState } from "./state";
import { Mutation, MutationsTypes } from "./mutations";
import router from "../routes";
import { GettersTypes } from "./getters";
// Payload types
type IAuthenticatePayload = { email: string, password: string, remember: boolean };
type IAuthenticateResult = { email?: string[], password?: string[] } | null;
type IAuthFetchPayload = { path: string, options?: RequestInit };
/**
* The available actions te perform
*/
export enum Action {
// Authentication
AuthAuthenticate = "AUTH_AUTHENTICATE",
AuthForget = "AUHT_FORGET",
AuthLoad = "AUTH_LOAD",
// RESTful
AuthFetch = "AUTH_FETCH"
}
/**
* The action function signatures
*/
export type ActionsTypes = {
// Authentication
[Action.AuthAuthenticate]: (payload: IAuthenticatePayload) => Promise<IAuthenticateResult>,
[Action.AuthForget] : () => void,
[Action.AuthLoad] : () => boolean
// RESTful
[Action.AuthFetch]: (payload: IAuthFetchPayload) => Promise<Response>
}
/**
* The action function implementations
*/
export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes> & ActionTree<IState, IState> = {
// Authentication ------------------------------------------------------------------------------
/**
* Authenticate the credentials of a user and log them in
*/
[Action.AuthAuthenticate]({commit, dispatch}, {email, password, remember = false}) {
return new Promise((resolve, reject) => {
fetch(`/auth/login?use_cookies=${navigator.cookieEnabled}`, {
method: "post",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({email, password})
})
.then(async response => {
let body = await response.json();
if (response.status !== 200) {
if (response.status === 401) {
body.errors = { "email": [ "Email or password is incorrect" ] };
}
resolve(body.errors || {});
return;
}
commit(Mutation.LoadUser, body.token);
commit(Mutation.StoreUser, remember);
resolve(null);
})
.catch(e => {
console.error("Error occurred during submission:", e);
reject(e);
});
});
},
/**
* Forget the login token and log the user out
*/
[Action.AuthForget]({commit}) {
commit(Mutation.ForgetUser, undefined);
},
/**
* Load the user from local storage
*/
[Action.AuthLoad]({getters, commit, dispatch}) {
let token = getters.storedToken;
if (!token) {
return false;
}
try {
commit(Mutation.LoadUser, token);
} catch(e) {
dispatch(Action.AuthForget, undefined);
return false;
}
return true;
},
// RESTful -------------------------------------------------------------------------------------
/**
* Fetch request providing authentication and logout upon unauthorized requests
*/
async [Action.AuthFetch]({commit, state}, {path, options = {}}) {
if (state.user == null) {
router.push({ name: "Login" });
throw Error("Unauthorized");
}
options.credentials = "include";
options.headers = Object.assign(options.headers ?? {}, {
"Authorization": `Bearer ${state.user.token}`
});
let response = await fetch(path, options);
if (response.status === 401) {
commit(Mutation.ForgetUser, undefined);
router.push({ name: "Login" });
throw Error("Unauthorized");
}
return response;
}
};

+ 36
- 0
src/app/store/generics.ts View File

@ -0,0 +1,36 @@
import { ActionContext, CommitOptions, DispatchOptions, GetterTree, MutationTree, Store as VuexStore } from "vuex";
type AugmentedActionContext<S, G extends GetterTree<S, S>, M extends MutationTree<S>, A extends ActionsType> = Omit<ActionContext<S, S>, "commit" | "dispatch" | "getters"> & {
commit<K extends keyof M>(key: K, payload: Parameters<M[K]>[1]): ReturnType<M[K]>,
dispatch<K extends keyof A, P extends Parameters<A[K]>[0]>(
key: K,
payload: P,
options?: DispatchOptions
): ReturnType<A[K]>,
getters: { [K in keyof G]: ReturnType<G[K]> }
};
type ActionsType = {
[key: string]: (payload?: any) => any
};
export type Actions<S, G extends GetterTree<S, S>, M extends MutationTree<S>, A extends ActionsType> = {
[K in keyof A]: (context: AugmentedActionContext<S, G, M, A>, payload: Parameters<A[K]>[0]) => ReturnType<A[K]>
}
// type InferActionTypes<A> = A extends { [key: string]: (context: any, payload: any) => any } ? {
// [K in keyof A]: (payload: Parameters<A[K]>[1]) => ReturnType<A[K]>
// } : never;
export type GenericStore<S, G extends GetterTree<S, S>, M extends MutationTree<S>, A extends ActionsType> = Omit<VuexStore<S>, "commit" | "getters" | "dispatch"> & {
commit<K extends keyof M, P extends Parameters<M[K]>[1]>(
key: K,
payload: P,
options?: CommitOptions
): ReturnType<M[K]>,
getters: { [K in keyof G]: ReturnType<G[K]> },
dispatch<K extends keyof A, P extends Parameters<A[K]>[0]>(
key: K,
payload: P,
options?: DispatchOptions
): ReturnType<A[K]>
};

+ 31
- 0
src/app/store/getters.ts View File

@ -0,0 +1,31 @@
import { GetterTree } from "vuex";
import { IState } from "./state";
export type GettersTypes = {
isAuthenticated(state: IState): boolean,
storedToken(): string | null,
token(state: IState): string | null
}
export const getters: GettersTypes & GetterTree<IState, IState> = {
/**
* Determine if the current user is authenticated
*/
isAuthenticated(state: IState) {
return state.user != null;
},
/**
* Retrieve the stored token
*/
storedToken() {
return sessionStorage.getItem("jwt") ?? localStorage.getItem("jwt");
},
/**
* Get the current token
*/
token(state: IState) {
return state.user?.token ?? null;
}
};

+ 36
- 0
src/app/store/index.ts View File

@ -0,0 +1,36 @@
import { createLogger, createStore } from "vuex";
import { GenericStore } from "./generics";
import { getters, GettersTypes } from "./getters";
// Import store information
import { state, IState } from "./state";
import { mutations, MutationsTypes } from "./mutations";
import { actions, ActionsTypes } from "./actions";
// Export store keys
export { Mutation } from "./mutations";
export { Action } from "./actions";
/**
* The the store type
*/
export type Store = GenericStore<IState, GettersTypes, MutationsTypes, ActionsTypes>;
/**
* Create the store instance
*/
const store = createStore({
state, getters, mutations, actions, plugins: [createLogger()]
}) as Store;
/**
* Use the store
*/
export function useStore() {
return store;
}
/**
* Export the store instance by default
*/
export default store;

+ 69
- 0
src/app/store/mutations.ts View File

@ -0,0 +1,69 @@
import jwtDecode from "jwt-decode";
import { MutationTree } from "vuex";
import { IState } from "./state";
/**
* All available mutation types
*/
export enum Mutation {
ForgetUser = "FORGET_USER",
LoadUser = "LOAD_USER",
StoreUser = "STORE_USER"
}
/**
* Type declarations for each mutation
*/
export type MutationsTypes<S = IState> = {
[Mutation.ForgetUser]: (state: S) => void,
[Mutation.LoadUser] : (state: S, token: string) => boolean,
[Mutation.StoreUser] : (state: S, remember: boolean) => void,
}
/**
* The auth mutations
*/
export const mutations: MutationsTypes & MutationTree<IState> = {
/**
* Log the user out and remove the token from storage
*/
[Mutation.ForgetUser](state: IState) {
state.user = null;
sessionStorage.removeItem("jwt");
localStorage.removeItem("jwt");
},
/**
* Try loading a user from the provided token
*/
[Mutation.LoadUser](state: IState, token: string) {
try {
let user: any = jwtDecode(token);
state.user = {
id : user.id,
name : user.name,
isAdmin: false,
token : token
}
} catch(e) {
return false;
}
return true;
},
/**
* Store the user to remember them
*/
[Mutation.StoreUser](state: IState, remember: boolean) {
if (state.user == null) {
sessionStorage.removeItem("jwt");
localStorage.removeItem("jwt");
return;
}
if (remember) {
localStorage.setItem("jwt", state.user.token);
} else {
sessionStorage.setItem("jwt", state.user.token);
}
}
}

+ 6
- 0
src/app/store/schema.ts View File

@ -0,0 +1,6 @@
export interface IUser {
id : number,
name : string,
isAdmin: boolean,
token : string
}

+ 15
- 0
src/app/store/state.ts View File

@ -0,0 +1,15 @@
import { IUser } from "./schema";
/**
* The state definition
*/
export interface IState {
user: IUser | null,
}
/**
* The state implementation
*/
export const state: IState = {
user: null
};

+ 2
- 3
src/app/views/Home.vue View File

@ -24,9 +24,9 @@
<script lang="ts">
import { defineComponent } from "vue";
import { IApiDataResponse } from "@common/api_schema";
import { IMovieDetails, IMovieSearchResult, IPaginatedResponse } from "@lib/tmdb/schema";
import { IMovieSearchResult, IPaginatedResponse } from "@lib/tmdb/schema";
import MovieModal from "../components/MovieModal.vue";
import { authFetch } from "../auth";
import { authFetch } from "../routes";
type MovieResults = IApiDataResponse<IPaginatedResponse<IMovieSearchResult>>;
@ -68,7 +68,6 @@ export default defineComponent({
}
},
mounted() {
console.log("Mounted main component");
this.search();
}
});


+ 20
- 34
src/app/views/Login.vue View File

@ -13,10 +13,10 @@
v-model="fields.password"/>
</div>
<div>
<check-box label="Remember Me" :disabled="isSubmitting"/>
<check-box label="Remember Me" :disabled="isSubmitting" v-model="fields.remember"/>
</div>
<div>
<button @click="login" :disabled="isSubmitting" class="block w-full rounded-full bg-indigo-500 text-white p-2 focus:outline-none">Sign In</button>
<button :disabled="isSubmitting" class="block w-full rounded-full bg-indigo-500 text-white p-2 focus:outline-none">Sign In</button>
</div>
</div>
</form>
@ -25,9 +25,9 @@
<script lang="ts">
import { defineComponent } from "vue";
import { Action } from "../store";
import CheckBox from "../components/CheckBox.vue";
import TextBox from "../components/TextBox.vue";
import * as auth from "../auth";
export default defineComponent({
components: {
@ -39,46 +39,32 @@ export default defineComponent({
isSubmitting: false,
fields: {
email: "",
password: ""
}
password: "",
remember: false
},
}
},
methods: {
login() {
async login() {
if (this.isSubmitting) {
return;
}
this.isSubmitting = true;
fetch(`/auth/login?use_cookies=${navigator.cookieEnabled}`, {
method: "post",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(this.fields)
})
.then(async response => {
this.isSubmitting = false;
let body = await response.json();
if (response.status !== 200) {
if (response.status === 401) {
body.errors = { "email": [ "Email or password is incorrect" ] };
}
if (body.errors) {
for (let fieldName in this.fields) {
let field = <any>this.$refs[fieldName];
let message = <string>(body.errors[fieldName] ?? [""])[0];
field.setErrorMessage(message);
}
try {
let errors = await this.$store.dispatch(Action.AuthAuthenticate, this.fields);
if (errors) {
for (let fieldName of ["email", "password"]) {
let field = <any>this.$refs[fieldName];
let message = <string>((<any>errors)[fieldName] ?? [""])[0];
field.setErrorMessage(message);
}
return;
} else {
this.$router.push({ name: "Home" });
}
auth.storeToken(body.token);
this.$router.push({ name: "Home" });
})
.catch(e => {
console.error("Error occurred during submission:", e);
this.isSubmitting = false;
});
} catch(e) {
console.error("Something went wrong logging in:", e);
}
this.isSubmitting = false;
}
}
});


+ 0
- 2
src/server/services/WebServer/middleware/auth.ts View File

@ -7,8 +7,6 @@ import { IteratorNext } from ".";
* Attempt to authenticate a client's JWT token
*/
function authenticateJwtToken<T = any>(request: FastifyRequest, reply: FastifyReply): T | undefined {
console.log("JWT Signature:", request.cookies["jwt_signature"]);
console.log(request.headers["authorization"]);
// Verify headers
if (!request.headers["authorization"]) {
reply.status(401);


+ 5
- 5
yarn.lock View File

@ -142,11 +142,6 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.37.tgz#a3dd8da4eb84a996c36e331df98d82abd76b516e"
integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==
"@types/node@^14.14.39":
version "14.14.39"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.39.tgz#9ef394d4eb52953d2890e4839393c309aa25d2d1"
integrity sha512-Qipn7rfTxGEDqZiezH+wxqWYR8vcXq5LRpZrETD19Gs4o8LbklbmqotSUsMU+s5G3PJwMRDfNEYoxrcBwIxOuw==
"@types/zen-observable@^0.8.2":
version "0.8.2"
resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71"
@ -3208,6 +3203,11 @@ vuedraggable@^4.0.1:
dependencies:
sortablejs "1.10.2"
vuex@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-4.0.0.tgz#ac877aa76a9c45368c979471e461b520d38e6cf5"
integrity sha512-56VPujlHscP5q/e7Jlpqc40sja4vOhC4uJD1llBCWolVI8ND4+VzisDVkUMl+z5y0MpIImW6HjhNc+ZvuizgOw==
websocket@^1.0.33:
version "1.0.33"
resolved "https://registry.yarnpkg.com/websocket/-/websocket-1.0.33.tgz#407f763fc58e74a3fa41ca3ae5d78d3f5e3b82a5"


Loading…
Cancel
Save