From 1365bdfd46161ea02184ac55f18276cd81b773a8 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Fri, 6 Oct 2023 22:44:43 -0500 Subject: [PATCH] refactor: rewrite to cookie based auth (#578) * rewrite to cookie based auth * remove interceptor --- backend/app/api/handlers/v1/controller.go | 7 ++ backend/app/api/handlers/v1/v1_ctrl_auth.go | 105 ++++++++++++++++++++ backend/app/api/middleware.go | 46 ++++----- frontend/composables/use-api.ts | 5 +- frontend/composables/use-auth-context.ts | 50 ++-------- frontend/layouts/default.vue | 1 + frontend/middleware/auth.ts | 1 + frontend/pages/index.vue | 11 +- 8 files changed, 155 insertions(+), 71 deletions(-) diff --git a/backend/app/api/handlers/v1/controller.go b/backend/app/api/handlers/v1/controller.go index 526b8ce..8d62cca 100644 --- a/backend/app/api/handlers/v1/controller.go +++ b/backend/app/api/handlers/v1/controller.go @@ -49,7 +49,14 @@ func WithRegistration(allowRegistration bool) func(*V1Controller) { } } +func WithSecureCookies(secure bool) func(*V1Controller) { + return func(ctrl *V1Controller) { + ctrl.cookieSecure = secure + } +} + type V1Controller struct { + cookieSecure bool repo *repo.AllRepos svc *services.AllServices maxUploadSize int64 diff --git a/backend/app/api/handlers/v1/v1_ctrl_auth.go b/backend/app/api/handlers/v1/v1_ctrl_auth.go index 14e864c..b66a8f0 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_auth.go +++ b/backend/app/api/handlers/v1/v1_ctrl_auth.go @@ -3,6 +3,7 @@ package v1 import ( "errors" "net/http" + "strconv" "strings" "time" @@ -13,6 +14,12 @@ import ( "github.com/rs/zerolog/log" ) +const ( + cookieNameToken = "hb.auth.token" + cookieNameRemember = "hb.auth.remember" + cookieNameSession = "hb.auth.session" +) + type ( TokenResponse struct { Token string `json:"token"` @@ -27,6 +34,30 @@ type ( } ) +type CookieContents struct { + Token string + ExpiresAt time.Time + Remember bool +} + +func GetCookies(r *http.Request) (*CookieContents, error) { + cookie, err := r.Cookie(cookieNameToken) + if err != nil { + return nil, errors.New("authorization cookie is required") + } + + rememberCookie, err := r.Cookie(cookieNameRemember) + if err != nil { + return nil, errors.New("remember cookie is required") + } + + return &CookieContents{ + Token: cookie.Value, + ExpiresAt: cookie.Expires, + Remember: rememberCookie.Value == "true", + }, nil +} + // HandleAuthLogin godoc // // @Summary User Login @@ -81,6 +112,7 @@ func (ctrl *V1Controller) HandleAuthLogin() errchain.HandlerFunc { return validate.NewRequestError(errors.New("authentication failed"), http.StatusInternalServerError) } + ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, loginForm.StayLoggedIn) return server.JSON(w, http.StatusOK, TokenResponse{ Token: "Bearer " + newToken.Raw, ExpiresAt: newToken.ExpiresAt, @@ -108,6 +140,7 @@ func (ctrl *V1Controller) HandleAuthLogout() errchain.HandlerFunc { return validate.NewRequestError(err, http.StatusInternalServerError) } + ctrl.unsetCookies(w, noPort(r.Host)) return server.JSON(w, http.StatusNoContent, nil) } } @@ -133,6 +166,78 @@ func (ctrl *V1Controller) HandleAuthRefresh() errchain.HandlerFunc { return validate.NewUnauthorizedError() } + ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false) return server.JSON(w, http.StatusOK, newToken) } } + +func noPort(host string) string { + return strings.Split(host, ":")[0] +} + +func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool) { + http.SetCookie(w, &http.Cookie{ + Name: cookieNameRemember, + Value: strconv.FormatBool(remember), + Expires: expires, + Domain: domain, + Secure: ctrl.cookieSecure, + HttpOnly: true, + Path: "/", + }) + + // Set HTTP only cookie + http.SetCookie(w, &http.Cookie{ + Name: cookieNameToken, + Value: token, + Expires: expires, + Domain: domain, + Secure: ctrl.cookieSecure, + HttpOnly: true, + Path: "/", + }) + + // Set Fake Session cookie + http.SetCookie(w, &http.Cookie{ + Name: cookieNameSession, + Value: "true", + Expires: expires, + Domain: domain, + Secure: ctrl.cookieSecure, + HttpOnly: false, + Path: "/", + }) +} + +func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) { + http.SetCookie(w, &http.Cookie{ + Name: cookieNameToken, + Value: "", + Expires: time.Unix(0, 0), + Domain: domain, + Secure: ctrl.cookieSecure, + HttpOnly: true, + Path: "/", + }) + + http.SetCookie(w, &http.Cookie{ + Name: cookieNameRemember, + Value: "false", + Expires: time.Unix(0, 0), + Domain: domain, + Secure: ctrl.cookieSecure, + HttpOnly: true, + Path: "/", + }) + + // Set Fake Session cookie + http.SetCookie(w, &http.Cookie{ + Name: cookieNameSession, + Value: "false", + Expires: time.Unix(0, 0), + Domain: domain, + Secure: ctrl.cookieSecure, + HttpOnly: false, + Path: "/", + }) +} diff --git a/backend/app/api/middleware.go b/backend/app/api/middleware.go index fb6b9cf..092e644 100644 --- a/backend/app/api/middleware.go +++ b/backend/app/api/middleware.go @@ -7,6 +7,7 @@ import ( "net/url" "strings" + v1 "github.com/hay-kot/homebox/backend/app/api/handlers/v1" "github.com/hay-kot/homebox/backend/internal/core/services" "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/httpkit/errchain" @@ -94,20 +95,6 @@ func getQuery(r *http.Request) (string, error) { 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 @@ -115,21 +102,30 @@ func getCookie(r *http.Request) (string, error) { // Example: // - header = "Bearer 1234567890" // - query = "?access_token=1234567890" -// - cookie = hb.auth.token = 1234567890 func (a *app) mwAuthToken(next errchain.Handler) errchain.Handler { return errchain.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - keyFuncs := [...]KeyFunc{ - getBearer, - getCookie, - getQuery, + var requestToken string + + // We ignore the error to allow the next strategy to be attempted + { + cookies, _ := v1.GetCookies(r) + if cookies != nil { + requestToken = cookies.Token + } } - var requestToken string - for _, keyFunc := range keyFuncs { - token, err := keyFunc(r) - if err == nil { - requestToken = token - break + if requestToken == "" { + keyFuncs := [...]KeyFunc{ + getBearer, + getQuery, + } + + for _, keyFunc := range keyFuncs { + token, err := keyFunc(r) + if err == nil { + requestToken = token + break + } } } diff --git a/frontend/composables/use-api.ts b/frontend/composables/use-api.ts index cf8cd7d..2eb4f88 100644 --- a/frontend/composables/use-api.ts +++ b/frontend/composables/use-api.ts @@ -30,12 +30,15 @@ export function usePublicApi(): PublicApi { export function useUserApi(): UserClient { const authCtx = useAuthContext(); - const requests = new Requests("", () => authCtx.token || "", {}); + const requests = new Requests("", "", {}); requests.addResponseInterceptor(logger); requests.addResponseInterceptor(r => { if (r.status === 401) { console.error("unauthorized request, invalidating session"); authCtx.invalidateSession(); + if (window.location.pathname !== "/") { + window.location.href = "/"; + } } }); diff --git a/frontend/composables/use-auth-context.ts b/frontend/composables/use-auth-context.ts index e366101..56fc639 100644 --- a/frontend/composables/use-auth-context.ts +++ b/frontend/composables/use-auth-context.ts @@ -4,8 +4,7 @@ import { UserOut } from "~~/lib/api/types/data-contracts"; import { UserClient } from "~~/lib/api/user"; export interface IAuthContext { - get token(): string | null; - get expiresAt(): string | null; + get token(): boolean | null; get attachmentToken(): string | null; /** @@ -13,11 +12,6 @@ export interface IAuthContext { */ user?: UserOut; - /** - * Returns true if the session is expired. - */ - isExpired(): boolean; - /** * Returns true if the session is authorized. */ @@ -43,59 +37,40 @@ class AuthContext implements IAuthContext { // eslint-disable-next-line no-use-before-define private static _instance?: AuthContext; - private static readonly cookieTokenKey = "hb.auth.token"; - private static readonly cookieExpiresAtKey = "hb.auth.expires_at"; + private static readonly cookieTokenKey = "hb.auth.session"; private static readonly cookieAttachmentTokenKey = "hb.auth.attachment_token"; user?: UserOut; private _token: CookieRef; - private _expiresAt: CookieRef; private _attachmentToken: CookieRef; get token() { - return this._token.value; - } - - get expiresAt() { - return this._expiresAt.value; + return this._token.value === "true"; } get attachmentToken() { return this._attachmentToken.value; } - private constructor(token: string, expiresAt: string, attachmentToken: string) { + private constructor(token: string, attachmentToken: string) { this._token = useCookie(token); - this._expiresAt = useCookie(expiresAt); this._attachmentToken = useCookie(attachmentToken); } static get instance() { if (!this._instance) { - this._instance = new AuthContext( - AuthContext.cookieTokenKey, - AuthContext.cookieExpiresAtKey, - AuthContext.cookieAttachmentTokenKey - ); + this._instance = new AuthContext(AuthContext.cookieTokenKey, AuthContext.cookieAttachmentTokenKey); } return this._instance; } isExpired() { - const expiresAt = this.expiresAt; - if (expiresAt === null) { - return true; - } - - const expiresAtDate = new Date(expiresAt); - const now = new Date(); - - return now.getTime() > expiresAtDate.getTime(); + return this.token; } isAuthorized() { - return !!this._token.value && !this.isExpired(); + return !this.isExpired(); } invalidateSession() { @@ -103,11 +78,9 @@ class AuthContext implements IAuthContext { // Delete the cookies this._token.value = null; - this._expiresAt.value = null; this._attachmentToken.value = null; console.log("Session invalidated"); - window.location.href = "/"; } async login(api: PublicApi, email: string, password: string, stayLoggedIn: boolean) { @@ -115,17 +88,10 @@ class AuthContext implements IAuthContext { if (!r.error) { const expiresAt = new Date(r.data.expiresAt); - this._token = useCookie(AuthContext.cookieTokenKey, { - expires: expiresAt, - }); - this._expiresAt = useCookie(AuthContext.cookieExpiresAtKey, { - expires: expiresAt, - }); + this._token = useCookie(AuthContext.cookieTokenKey); this._attachmentToken = useCookie(AuthContext.cookieAttachmentTokenKey, { expires: expiresAt, }); - this._token.value = r.data.token; - this._expiresAt.value = r.data.expiresAt as string; this._attachmentToken.value = r.data.attachmentToken; } diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index e5e590c..769606b 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -199,5 +199,6 @@ async function logout() { await authCtx.logout(api); + navigateTo("/"); } diff --git a/frontend/middleware/auth.ts b/frontend/middleware/auth.ts index dd41635..25aebeb 100644 --- a/frontend/middleware/auth.ts +++ b/frontend/middleware/auth.ts @@ -7,6 +7,7 @@ export default defineNuxtRouteMiddleware(async () => { } if (!ctx.user) { + console.log("Fetching user data"); const { data, error } = await api.user.self(); if (error) { return navigateTo("/"); diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 2258c4c..81266b5 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -5,12 +5,17 @@ definePageMeta({ layout: "empty", + middleware: [ + () => { + const ctx = useAuthContext(); + if (ctx.isAuthorized()) { + return "/home"; + } + }, + ], }); const ctx = useAuthContext(); - if (ctx.isAuthorized()) { - navigateTo("/home"); - } const api = usePublicApi(); const toast = useNotifier();