refactor: rewrite to cookie based auth (#578)

* rewrite to cookie based auth

* remove interceptor

Former-commit-id: 1365bdfd46
This commit is contained in:
Hayden 2023-10-06 22:44:43 -05:00 committed by GitHub
parent 36e13ab03b
commit c71f077466
8 changed files with 155 additions and 71 deletions

View file

@ -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 { type V1Controller struct {
cookieSecure bool
repo *repo.AllRepos repo *repo.AllRepos
svc *services.AllServices svc *services.AllServices
maxUploadSize int64 maxUploadSize int64

View file

@ -3,6 +3,7 @@ package v1
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@ -13,6 +14,12 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
const (
cookieNameToken = "hb.auth.token"
cookieNameRemember = "hb.auth.remember"
cookieNameSession = "hb.auth.session"
)
type ( type (
TokenResponse struct { TokenResponse struct {
Token string `json:"token"` 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 // HandleAuthLogin godoc
// //
// @Summary User Login // @Summary User Login
@ -81,6 +112,7 @@ func (ctrl *V1Controller) HandleAuthLogin() errchain.HandlerFunc {
return validate.NewRequestError(errors.New("authentication failed"), http.StatusInternalServerError) 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{ return server.JSON(w, http.StatusOK, TokenResponse{
Token: "Bearer " + newToken.Raw, Token: "Bearer " + newToken.Raw,
ExpiresAt: newToken.ExpiresAt, ExpiresAt: newToken.ExpiresAt,
@ -108,6 +140,7 @@ func (ctrl *V1Controller) HandleAuthLogout() errchain.HandlerFunc {
return validate.NewRequestError(err, http.StatusInternalServerError) return validate.NewRequestError(err, http.StatusInternalServerError)
} }
ctrl.unsetCookies(w, noPort(r.Host))
return server.JSON(w, http.StatusNoContent, nil) return server.JSON(w, http.StatusNoContent, nil)
} }
} }
@ -133,6 +166,78 @@ func (ctrl *V1Controller) HandleAuthRefresh() errchain.HandlerFunc {
return validate.NewUnauthorizedError() return validate.NewUnauthorizedError()
} }
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false)
return server.JSON(w, http.StatusOK, newToken) 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: "/",
})
}

View file

@ -7,6 +7,7 @@ import (
"net/url" "net/url"
"strings" "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/core/services"
"github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/internal/sys/validate"
"github.com/hay-kot/httpkit/errchain" "github.com/hay-kot/httpkit/errchain"
@ -94,20 +95,6 @@ func getQuery(r *http.Request) (string, error) {
return token, nil 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
@ -115,21 +102,30 @@ func getCookie(r *http.Request) (string, error) {
// 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 errchain.Handler) errchain.Handler { func (a *app) mwAuthToken(next errchain.Handler) errchain.Handler {
return errchain.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return errchain.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
keyFuncs := [...]KeyFunc{ var requestToken string
getBearer,
getCookie, // We ignore the error to allow the next strategy to be attempted
getQuery, {
cookies, _ := v1.GetCookies(r)
if cookies != nil {
requestToken = cookies.Token
}
} }
var requestToken string if requestToken == "" {
for _, keyFunc := range keyFuncs { keyFuncs := [...]KeyFunc{
token, err := keyFunc(r) getBearer,
if err == nil { getQuery,
requestToken = token }
break
for _, keyFunc := range keyFuncs {
token, err := keyFunc(r)
if err == nil {
requestToken = token
break
}
} }
} }

View file

@ -30,12 +30,15 @@ export function usePublicApi(): PublicApi {
export function useUserApi(): UserClient { export function useUserApi(): UserClient {
const authCtx = useAuthContext(); const authCtx = useAuthContext();
const requests = new Requests("", () => authCtx.token || "", {}); const requests = new Requests("", "", {});
requests.addResponseInterceptor(logger); requests.addResponseInterceptor(logger);
requests.addResponseInterceptor(r => { requests.addResponseInterceptor(r => {
if (r.status === 401) { if (r.status === 401) {
console.error("unauthorized request, invalidating session"); console.error("unauthorized request, invalidating session");
authCtx.invalidateSession(); authCtx.invalidateSession();
if (window.location.pathname !== "/") {
window.location.href = "/";
}
} }
}); });

View file

@ -4,8 +4,7 @@ import { UserOut } from "~~/lib/api/types/data-contracts";
import { UserClient } from "~~/lib/api/user"; import { UserClient } from "~~/lib/api/user";
export interface IAuthContext { export interface IAuthContext {
get token(): string | null; get token(): boolean | null;
get expiresAt(): string | null;
get attachmentToken(): string | null; get attachmentToken(): string | null;
/** /**
@ -13,11 +12,6 @@ export interface IAuthContext {
*/ */
user?: UserOut; user?: UserOut;
/**
* Returns true if the session is expired.
*/
isExpired(): boolean;
/** /**
* Returns true if the session is authorized. * Returns true if the session is authorized.
*/ */
@ -43,59 +37,40 @@ class AuthContext implements IAuthContext {
// eslint-disable-next-line no-use-before-define // eslint-disable-next-line no-use-before-define
private static _instance?: AuthContext; private static _instance?: AuthContext;
private static readonly cookieTokenKey = "hb.auth.token"; private static readonly cookieTokenKey = "hb.auth.session";
private static readonly cookieExpiresAtKey = "hb.auth.expires_at";
private static readonly cookieAttachmentTokenKey = "hb.auth.attachment_token"; private static readonly cookieAttachmentTokenKey = "hb.auth.attachment_token";
user?: UserOut; user?: UserOut;
private _token: CookieRef<string | null>; private _token: CookieRef<string | null>;
private _expiresAt: CookieRef<string | null>;
private _attachmentToken: CookieRef<string | null>; private _attachmentToken: CookieRef<string | null>;
get token() { get token() {
return this._token.value; return this._token.value === "true";
}
get expiresAt() {
return this._expiresAt.value;
} }
get attachmentToken() { get attachmentToken() {
return this._attachmentToken.value; return this._attachmentToken.value;
} }
private constructor(token: string, expiresAt: string, attachmentToken: string) { private constructor(token: string, attachmentToken: string) {
this._token = useCookie(token); this._token = useCookie(token);
this._expiresAt = useCookie(expiresAt);
this._attachmentToken = useCookie(attachmentToken); this._attachmentToken = useCookie(attachmentToken);
} }
static get instance() { static get instance() {
if (!this._instance) { if (!this._instance) {
this._instance = new AuthContext( this._instance = new AuthContext(AuthContext.cookieTokenKey, AuthContext.cookieAttachmentTokenKey);
AuthContext.cookieTokenKey,
AuthContext.cookieExpiresAtKey,
AuthContext.cookieAttachmentTokenKey
);
} }
return this._instance; return this._instance;
} }
isExpired() { isExpired() {
const expiresAt = this.expiresAt; return this.token;
if (expiresAt === null) {
return true;
}
const expiresAtDate = new Date(expiresAt);
const now = new Date();
return now.getTime() > expiresAtDate.getTime();
} }
isAuthorized() { isAuthorized() {
return !!this._token.value && !this.isExpired(); return !this.isExpired();
} }
invalidateSession() { invalidateSession() {
@ -103,11 +78,9 @@ class AuthContext implements IAuthContext {
// Delete the cookies // Delete the cookies
this._token.value = null; this._token.value = null;
this._expiresAt.value = null;
this._attachmentToken.value = null; this._attachmentToken.value = null;
console.log("Session invalidated"); console.log("Session invalidated");
window.location.href = "/";
} }
async login(api: PublicApi, email: string, password: string, stayLoggedIn: boolean) { async login(api: PublicApi, email: string, password: string, stayLoggedIn: boolean) {
@ -115,17 +88,10 @@ class AuthContext implements IAuthContext {
if (!r.error) { if (!r.error) {
const expiresAt = new Date(r.data.expiresAt); const expiresAt = new Date(r.data.expiresAt);
this._token = useCookie(AuthContext.cookieTokenKey, { this._token = useCookie(AuthContext.cookieTokenKey);
expires: expiresAt,
});
this._expiresAt = useCookie(AuthContext.cookieExpiresAtKey, {
expires: expiresAt,
});
this._attachmentToken = useCookie(AuthContext.cookieAttachmentTokenKey, { this._attachmentToken = useCookie(AuthContext.cookieAttachmentTokenKey, {
expires: expiresAt, expires: expiresAt,
}); });
this._token.value = r.data.token;
this._expiresAt.value = r.data.expiresAt as string;
this._attachmentToken.value = r.data.attachmentToken; this._attachmentToken.value = r.data.attachmentToken;
} }

View file

@ -199,5 +199,6 @@
async function logout() { async function logout() {
await authCtx.logout(api); await authCtx.logout(api);
navigateTo("/");
} }
</script> </script>

View file

@ -7,6 +7,7 @@ export default defineNuxtRouteMiddleware(async () => {
} }
if (!ctx.user) { if (!ctx.user) {
console.log("Fetching user data");
const { data, error } = await api.user.self(); const { data, error } = await api.user.self();
if (error) { if (error) {
return navigateTo("/"); return navigateTo("/");

View file

@ -5,12 +5,17 @@
definePageMeta({ definePageMeta({
layout: "empty", layout: "empty",
middleware: [
() => {
const ctx = useAuthContext();
if (ctx.isAuthorized()) {
return "/home";
}
},
],
}); });
const ctx = useAuthContext(); const ctx = useAuthContext();
if (ctx.isAuthorized()) {
navigateTo("/home");
}
const api = usePublicApi(); const api = usePublicApi();
const toast = useNotifier(); const toast = useNotifier();