Skip to content

Commit

Permalink
feat: change auth to use cookies (#301)
Browse files Browse the repository at this point in the history
* frontend cookie implementation

* accept cookies for authentication

* remove auth store

* add self attr
  • Loading branch information
hay-kot committed Feb 18, 2023
1 parent bd321af commit 12975ce
Show file tree
Hide file tree
Showing 9 changed files with 204 additions and 86 deletions.
66 changes: 58 additions & 8 deletions backend/app/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"net/url"
"strings"

"github.com/hay-kot/homebox/backend/internal/core/services"
Expand Down Expand Up @@ -68,33 +69,82 @@ func (a *app) mwRoles(rm RoleMode, required ...string) server.Middleware {
}
}

type KeyFunc func(r *http.Request) (string, error)

func getBearer(r *http.Request) (string, error) {
auth := r.Header.Get("Authorization")
if auth == "" {
return "", errors.New("authorization header is required")
}

return auth, nil
}

func getQuery(r *http.Request) (string, error) {
token := r.URL.Query().Get("access_token")
if token == "" {
return "", errors.New("access_token query is required")
}

token, err := url.QueryUnescape(token)
if err != nil {
return "", errors.New("access_token query is required")
}

return token, nil
}

func getCookie(r *http.Request) (string, error) {
cookie, err := r.Cookie("hb.auth.token")
if err != nil {
return "", errors.New("access_token cookie is required")
}

token, err := url.QueryUnescape(cookie.Value)
if err != nil {
return "", errors.New("access_token cookie is required")
}

return token, nil
}

// mwAuthToken is a middleware that will check the database for a stateful token
// and attach it's user to the request context, or return an appropriate error.
// Authorization support is by token via Headers or Query Parameter
//
// Example:
// - header = "Bearer 1234567890"
// - query = "?access_token=1234567890"
// - cookie = hb.auth.token = 1234567890
func (a *app) mwAuthToken(next server.Handler) server.Handler {
return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
requestToken := r.Header.Get("Authorization")
if requestToken == "" {
// check for query param
requestToken = r.URL.Query().Get("access_token")
if requestToken == "" {
return validate.NewRequestError(errors.New("Authorization header or query is required"), http.StatusUnauthorized)
keyFuncs := [...]KeyFunc{
getBearer,
getCookie,
getQuery,
}

var requestToken string
for _, keyFunc := range keyFuncs {
token, err := keyFunc(r)
if err == nil {
requestToken = token
break
}
}

if requestToken == "" {
return validate.NewRequestError(errors.New("Authorization header or query is required"), http.StatusUnauthorized)
}

requestToken = strings.TrimPrefix(requestToken, "Bearer ")

r = r.WithContext(context.WithValue(r.Context(), hashedToken, requestToken))

usr, err := a.services.User.GetSelf(r.Context(), requestToken)

// Check the database for the token
if err != nil {
return validate.NewRequestError(errors.New("Authorization header is required"), http.StatusUnauthorized)
return validate.NewRequestError(errors.New("valid authorization header is required"), http.StatusUnauthorized)
}

r = r.WithContext(services.SetUserCtx(r.Context(), &usr, requestToken))
Expand Down
6 changes: 2 additions & 4 deletions frontend/components/App/Header.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
<script lang="ts" setup>
import { useAuthStore } from "~~/stores/auth";
const authStore = useAuthStore();
const ctx = useAuthContext();
const api = useUserApi();
async function logout() {
const { error } = await authStore.logout(api);
const { error } = await ctx.logout(api);
if (error) {
return;
}
Expand Down
9 changes: 4 additions & 5 deletions frontend/composables/use-api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { PublicApi } from "~~/lib/api/public";
import { UserClient } from "~~/lib/api/user";
import { Requests } from "~~/lib/requests";
import { useAuthStore } from "~~/stores/auth";

export type Observer = {
handler: (r: Response, req?: RequestInit) => void;
Expand Down Expand Up @@ -29,19 +28,19 @@ export function usePublicApi(): PublicApi {
}

export function useUserApi(): UserClient {
const authStore = useAuthStore();
const authCtx = useAuthContext();

const requests = new Requests("", () => authStore.token, {});
const requests = new Requests("", () => authCtx.token || "", {});
requests.addResponseInterceptor(logger);
requests.addResponseInterceptor(r => {
if (r.status === 401) {
authStore.clearSession();
authCtx.invalidateSession();
}
});

for (const [_, observer] of Object.entries(observers)) {
requests.addResponseInterceptor(observer.handler);
}

return new UserClient(requests, authStore.attachmentToken);
return new UserClient(requests, authCtx.attachmentToken || "");
}
129 changes: 129 additions & 0 deletions frontend/composables/use-auth-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { CookieRef } from "nuxt/dist/app/composables";
import { PublicApi } from "~~/lib/api/public";
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;

/**
* The current user object for the session. This is undefined if the session is not authorized.
*/
user?: UserOut;

/**
* Returns true if the session is expired.
*/
isExpired(): boolean;

/**
* Returns true if the session is authorized.
*/
isAuthorized(): boolean;

/**
* Invalidates the session by removing the token and the expiresAt.
*/
invalidateSession(): void;

/**
* Logs out the user and calls the invalidateSession method.
*/
logout(api: UserClient): ReturnType<UserClient["user"]["logout"]>;

/**
* Logs in the user and sets the authorization context via cookies
*/
login(api: PublicApi, email: string, password: string): ReturnType<PublicApi["login"]>;
}

class AuthContext implements IAuthContext {
user?: UserOut;
private _token: CookieRef<string | null>;
private _expiresAt: CookieRef<string | null>;
private _attachmentToken: CookieRef<string | null>;

get token() {
return this._token.value;
}

get expiresAt() {
return this._expiresAt.value;
}

get attachmentToken() {
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;
}

isExpired() {
const expiresAt = this.expiresAt;
if (expiresAt === null) {
return true;
}

const expiresAtDate = new Date(expiresAt);
const now = new Date();

return now.getTime() > expiresAtDate.getTime();
}

isAuthorized() {
return this._token.value !== null && !this.isExpired();
}

invalidateSession() {
this.user = undefined;
this._token.value = null;
this._expiresAt.value = null;
this._attachmentToken.value = null;
}

async login(api: PublicApi, email: string, password: string) {
const r = await api.login(email, password);

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;
}

async logout(api: UserClient) {
const r = await api.user.logout();

if (!r.error) {
this.invalidateSession();
}

return r;
}
}

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);
}
7 changes: 3 additions & 4 deletions frontend/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,10 @@
</template>
<script lang="ts" setup>
import { useAuthStore } from "~~/stores/auth";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
const username = computed(() => authStore.self?.name || "User");
const username = computed(() => authCtx.self?.name || "User");
// Preload currency format
useFormatCurrency();
Expand Down Expand Up @@ -223,11 +222,11 @@
eventBus.off(EventTypes.InvalidStores, "stores");
});
const authStore = useAuthStore();
const authCtx = useAuthContext();
const api = useUserApi();
async function logout() {
const { error } = await authStore.logout(api);
const { error } = await authCtx.logout(api);
if (error) {
return;
}
Expand Down
8 changes: 3 additions & 5 deletions frontend/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { useAuthStore } from "~~/stores/auth";

export default defineNuxtRouteMiddleware(async () => {
const auth = useAuthStore();
const ctx = useAuthContext();
const api = useUserApi();

if (!auth.self) {
if (!ctx.user) {
const { data, error } = await api.user.self();
if (error) {
navigateTo("/");
}

auth.$patch({ self: data.item });
ctx.user = data.item;
}
});
15 changes: 4 additions & 11 deletions frontend/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { useAuthStore } from "~~/stores/auth";
useHead({
title: "Homebox | Organize and Tag Your Stuff",
});
Expand All @@ -8,6 +7,8 @@
layout: "empty",
});
const ctx = useAuthContext();
const api = usePublicApi();
const toast = useNotifier();
Expand All @@ -28,8 +29,7 @@
}
});
const authStore = useAuthStore();
if (!authStore.isTokenExpired) {
if (!ctx.isAuthorized()) {
navigateTo("/home");
}
Expand Down Expand Up @@ -91,7 +91,7 @@
async function login() {
loading.value = true;
const { data, error } = await api.login(email.value, loginPassword.value);
const { error } = await ctx.login(api, email.value, loginPassword.value);
if (error) {
toast.error("Invalid email or password");
Expand All @@ -101,13 +101,6 @@
toast.success("Logged in successfully");
// @ts-ignore
authStore.$patch({
token: data.token,
expires: data.expiresAt,
attachmentToken: data.attachmentToken,
});
navigateTo("/home");
loading.value = false;
}
Expand Down
3 changes: 1 addition & 2 deletions frontend/pages/profile.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { Detail } from "~~/components/global/DetailsSection/types";
import { useAuthStore } from "~~/stores/auth";
import { themes } from "~~/lib/data/themes";
import { currencies, Currency } from "~~/lib/data/currency";
Expand Down Expand Up @@ -79,7 +78,7 @@
const { setTheme } = useTheme();
const auth = useAuthStore();
const auth = useAuthContext();
const details = computed(() => {
return [
Expand Down

0 comments on commit 12975ce

Please sign in to comment.