forked from mirrors/homebox
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 {
|
||||
cookieSecure bool
|
||||
repo *repo.AllRepos
|
||||
svc *services.AllServices
|
||||
maxUploadSize int64
|
||||
|
|
|
@ -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: "/",
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,16 +102,24 @@ 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 {
|
||||
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{
|
||||
getBearer,
|
||||
getCookie,
|
||||
getQuery,
|
||||
}
|
||||
|
||||
var requestToken string
|
||||
for _, keyFunc := range keyFuncs {
|
||||
token, err := keyFunc(r)
|
||||
if err == nil {
|
||||
|
@ -132,6 +127,7 @@ func (a *app) mwAuthToken(next errchain.Handler) errchain.Handler {
|
|||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestToken == "" {
|
||||
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 {
|
||||
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 = "/";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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<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() {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -199,5 +199,6 @@
|
|||
|
||||
async function logout() {
|
||||
await authCtx.logout(api);
|
||||
navigateTo("/");
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -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("/");
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in a new issue