Skip to content

Commit

Permalink
refactor: rewrite to cookie based auth (#578)
Browse files Browse the repository at this point in the history
* rewrite to cookie based auth

* remove interceptor
  • Loading branch information
hay-kot committed Oct 7, 2023
1 parent 2cd3c15 commit 1365bdf
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 71 deletions.
7 changes: 7 additions & 0 deletions backend/app/api/handlers/v1/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions backend/app/api/handlers/v1/v1_ctrl_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package v1
import (
"errors"
"net/http"
"strconv"
"strings"
"time"

Expand All @@ -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"`
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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: "/",
})
}
46 changes: 21 additions & 25 deletions backend/app/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -94,42 +95,37 @@ 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
//
// 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
}
}
}

Expand Down
5 changes: 4 additions & 1 deletion frontend/composables/use-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "/";
}
}
});

Expand Down
50 changes: 8 additions & 42 deletions frontend/composables/use-auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,14 @@ 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;

/**
* 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.
*/
Expand All @@ -43,89 +37,61 @@ 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<string | null>;
private _expiresAt: CookieRef<string | null>;
private _attachmentToken: CookieRef<string | null>;

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() {
this.user = undefined;

// 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) {
const r = await api.login(email, password, stayLoggedIn);

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

Expand Down
1 change: 1 addition & 0 deletions frontend/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -199,5 +199,6 @@
async function logout() {
await authCtx.logout(api);
navigateTo("/");
}
</script>
1 change: 1 addition & 0 deletions frontend/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");
Expand Down

0 comments on commit 1365bdf

Please sign in to comment.