forked from mirrors/homebox
feat: change auth to use cookies (#301)
* frontend cookie implementation * accept cookies for authentication * remove auth store * add self attr
This commit is contained in:
parent
bd321af29f
commit
12975ce26e
9 changed files with 204 additions and 86 deletions
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hay-kot/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
@ -68,6 +69,45 @@ 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
|
// 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.
|
// and attach it's user to the request context, or return an appropriate error.
|
||||||
// Authorization support is by token via Headers or Query Parameter
|
// Authorization support is by token via Headers or Query Parameter
|
||||||
|
@ -75,26 +115,36 @@ func (a *app) mwRoles(rm RoleMode, required ...string) server.Middleware {
|
||||||
// Example:
|
// Example:
|
||||||
// - header = "Bearer 1234567890"
|
// - header = "Bearer 1234567890"
|
||||||
// - query = "?access_token=1234567890"
|
// - query = "?access_token=1234567890"
|
||||||
|
// - cookie = hb.auth.token = 1234567890
|
||||||
func (a *app) mwAuthToken(next server.Handler) server.Handler {
|
func (a *app) mwAuthToken(next server.Handler) server.Handler {
|
||||||
return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
requestToken := r.Header.Get("Authorization")
|
keyFuncs := [...]KeyFunc{
|
||||||
if requestToken == "" {
|
getBearer,
|
||||||
// check for query param
|
getCookie,
|
||||||
requestToken = r.URL.Query().Get("access_token")
|
getQuery,
|
||||||
if requestToken == "" {
|
}
|
||||||
return validate.NewRequestError(errors.New("Authorization header or query is required"), http.StatusUnauthorized)
|
|
||||||
|
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 ")
|
requestToken = strings.TrimPrefix(requestToken, "Bearer ")
|
||||||
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), hashedToken, requestToken))
|
r = r.WithContext(context.WithValue(r.Context(), hashedToken, requestToken))
|
||||||
|
|
||||||
usr, err := a.services.User.GetSelf(r.Context(), requestToken)
|
usr, err := a.services.User.GetSelf(r.Context(), requestToken)
|
||||||
|
|
||||||
// Check the database for the token
|
// Check the database for the token
|
||||||
if err != nil {
|
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))
|
r = r.WithContext(services.SetUserCtx(r.Context(), &usr, requestToken))
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useAuthStore } from "~~/stores/auth";
|
const ctx = useAuthContext();
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
const { error } = await authStore.logout(api);
|
const { error } = await ctx.logout(api);
|
||||||
if (error) {
|
if (error) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { PublicApi } from "~~/lib/api/public";
|
import { PublicApi } from "~~/lib/api/public";
|
||||||
import { UserClient } from "~~/lib/api/user";
|
import { UserClient } from "~~/lib/api/user";
|
||||||
import { Requests } from "~~/lib/requests";
|
import { Requests } from "~~/lib/requests";
|
||||||
import { useAuthStore } from "~~/stores/auth";
|
|
||||||
|
|
||||||
export type Observer = {
|
export type Observer = {
|
||||||
handler: (r: Response, req?: RequestInit) => void;
|
handler: (r: Response, req?: RequestInit) => void;
|
||||||
|
@ -29,13 +28,13 @@ export function usePublicApi(): PublicApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUserApi(): UserClient {
|
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(logger);
|
||||||
requests.addResponseInterceptor(r => {
|
requests.addResponseInterceptor(r => {
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
authStore.clearSession();
|
authCtx.invalidateSession();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -43,5 +42,5 @@ export function useUserApi(): UserClient {
|
||||||
requests.addResponseInterceptor(observer.handler);
|
requests.addResponseInterceptor(observer.handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new UserClient(requests, authStore.attachmentToken);
|
return new UserClient(requests, authCtx.attachmentToken || "");
|
||||||
}
|
}
|
||||||
|
|
129
frontend/composables/use-auth-context.ts
Normal file
129
frontend/composables/use-auth-context.ts
Normal file
|
@ -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);
|
||||||
|
}
|
|
@ -91,11 +91,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useAuthStore } from "~~/stores/auth";
|
|
||||||
import { useLabelStore } from "~~/stores/labels";
|
import { useLabelStore } from "~~/stores/labels";
|
||||||
import { useLocationStore } from "~~/stores/locations";
|
import { useLocationStore } from "~~/stores/locations";
|
||||||
|
|
||||||
const username = computed(() => authStore.self?.name || "User");
|
const username = computed(() => authCtx.self?.name || "User");
|
||||||
|
|
||||||
// Preload currency format
|
// Preload currency format
|
||||||
useFormatCurrency();
|
useFormatCurrency();
|
||||||
|
@ -223,11 +222,11 @@
|
||||||
eventBus.off(EventTypes.InvalidStores, "stores");
|
eventBus.off(EventTypes.InvalidStores, "stores");
|
||||||
});
|
});
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authCtx = useAuthContext();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
const { error } = await authStore.logout(api);
|
const { error } = await authCtx.logout(api);
|
||||||
if (error) {
|
if (error) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
import { useAuthStore } from "~~/stores/auth";
|
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async () => {
|
export default defineNuxtRouteMiddleware(async () => {
|
||||||
const auth = useAuthStore();
|
const ctx = useAuthContext();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
|
||||||
if (!auth.self) {
|
if (!ctx.user) {
|
||||||
const { data, error } = await api.user.self();
|
const { data, error } = await api.user.self();
|
||||||
if (error) {
|
if (error) {
|
||||||
navigateTo("/");
|
navigateTo("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.$patch({ self: data.item });
|
ctx.user = data.item;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from "~~/stores/auth";
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Homebox | Organize and Tag Your Stuff",
|
title: "Homebox | Organize and Tag Your Stuff",
|
||||||
});
|
});
|
||||||
|
@ -8,6 +7,8 @@
|
||||||
layout: "empty",
|
layout: "empty",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ctx = useAuthContext();
|
||||||
|
|
||||||
const api = usePublicApi();
|
const api = usePublicApi();
|
||||||
const toast = useNotifier();
|
const toast = useNotifier();
|
||||||
|
|
||||||
|
@ -28,8 +29,7 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
if (!ctx.isAuthorized()) {
|
||||||
if (!authStore.isTokenExpired) {
|
|
||||||
navigateTo("/home");
|
navigateTo("/home");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,7 +91,7 @@
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
loading.value = true;
|
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) {
|
if (error) {
|
||||||
toast.error("Invalid email or password");
|
toast.error("Invalid email or password");
|
||||||
|
@ -101,13 +101,6 @@
|
||||||
|
|
||||||
toast.success("Logged in successfully");
|
toast.success("Logged in successfully");
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
authStore.$patch({
|
|
||||||
token: data.token,
|
|
||||||
expires: data.expiresAt,
|
|
||||||
attachmentToken: data.attachmentToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
navigateTo("/home");
|
navigateTo("/home");
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Detail } from "~~/components/global/DetailsSection/types";
|
import { Detail } from "~~/components/global/DetailsSection/types";
|
||||||
import { useAuthStore } from "~~/stores/auth";
|
|
||||||
import { themes } from "~~/lib/data/themes";
|
import { themes } from "~~/lib/data/themes";
|
||||||
import { currencies, Currency } from "~~/lib/data/currency";
|
import { currencies, Currency } from "~~/lib/data/currency";
|
||||||
|
|
||||||
|
@ -79,7 +78,7 @@
|
||||||
|
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthContext();
|
||||||
|
|
||||||
const details = computed(() => {
|
const details = computed(() => {
|
||||||
return [
|
return [
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
import { defineStore } from "pinia";
|
|
||||||
import { useLocalStorage } from "@vueuse/core";
|
|
||||||
import { UserClient } from "~~/lib/api/user";
|
|
||||||
import { UserOut } from "~~/lib/api/types/data-contracts";
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore("auth", {
|
|
||||||
state: () => ({
|
|
||||||
token: useLocalStorage("pinia/auth/token", ""),
|
|
||||||
attachmentToken: useLocalStorage("pinia/auth/attachmentToken", ""),
|
|
||||||
expires: useLocalStorage("pinia/auth/expires", ""),
|
|
||||||
self: null as UserOut | null,
|
|
||||||
}),
|
|
||||||
getters: {
|
|
||||||
isTokenExpired: state => {
|
|
||||||
if (!state.expires) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof state.expires === "string") {
|
|
||||||
return new Date(state.expires) < new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.expires < new Date();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
async logout(api: UserClient) {
|
|
||||||
const result = await api.user.logout();
|
|
||||||
|
|
||||||
this.token = "";
|
|
||||||
this.attachmentToken = "";
|
|
||||||
this.expires = "";
|
|
||||||
this.self = null;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* clearSession is used when the user cannot be logged out via the API and
|
|
||||||
* must clear it's local session, usually when a 401 is received.
|
|
||||||
*/
|
|
||||||
clearSession() {
|
|
||||||
this.token = "";
|
|
||||||
this.expires = "";
|
|
||||||
navigateTo("/");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
Loading…
Reference in a new issue