mirror of
https://github.com/hay-kot/homebox.git
synced 2024-11-22 08:35:43 +00:00
refactor: rewrite to cookie based auth (#578)
* rewrite to cookie based auth * remove interceptor
This commit is contained in:
parent
2cd3c15215
commit
1365bdfd46
8 changed files with 155 additions and 71 deletions
|
@ -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
|
||||||
|
|
|
@ -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: "/",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -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,16 +102,24 @@ 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestToken == "" {
|
||||||
keyFuncs := [...]KeyFunc{
|
keyFuncs := [...]KeyFunc{
|
||||||
getBearer,
|
getBearer,
|
||||||
getCookie,
|
|
||||||
getQuery,
|
getQuery,
|
||||||
}
|
}
|
||||||
|
|
||||||
var requestToken string
|
|
||||||
for _, keyFunc := range keyFuncs {
|
for _, keyFunc := range keyFuncs {
|
||||||
token, err := keyFunc(r)
|
token, err := keyFunc(r)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
@ -132,6 +127,7 @@ func (a *app) mwAuthToken(next errchain.Handler) errchain.Handler {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if requestToken == "" {
|
if requestToken == "" {
|
||||||
return validate.NewRequestError(errors.New("Authorization header or query is required"), http.StatusUnauthorized)
|
return validate.NewRequestError(errors.New("Authorization header or query is required"), http.StatusUnauthorized)
|
||||||
|
|
|
@ -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 = "/";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -199,5 +199,6 @@
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await authCtx.logout(api);
|
await authCtx.logout(api);
|
||||||
|
navigateTo("/");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -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("/");
|
||||||
|
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in a new issue