Support for Cookie authentication (#390)
- Added support for cookie authentication (authorization header will have priority) - Removed local storage token management from UI - cookie hardening (sameSite, httpOnly, secure) - login endpoint sets cookie via header, logout endpoint expires cookie - Refactor Routes and ProtectedRoutes components, improvement on the way application check if user session is valid Future improvements - look for all places in backend that returns 401 unauthorized, and destroy session there (not a priority since cookie its invalid anyway) - Downloading objects in object browser can be simplified since is just a GET request and users will be authenticated via Cookies, no need to craft additional requests
This commit is contained in:
File diff suppressed because one or more lines are too long
68
portal-ui/src/ProtectedRoutes.tsx
Normal file
68
portal-ui/src/ProtectedRoutes.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2020 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { connect } from "react-redux";
|
||||
import { AppState } from "./store";
|
||||
import { userLoggedIn } from "./actions";
|
||||
import api from "./common/api";
|
||||
import { clearSession } from "./common/utils";
|
||||
import { saveSessionResponse } from "./screens/Console/actions";
|
||||
|
||||
const mapState = (state: AppState) => ({
|
||||
loggedIn: state.system.loggedIn,
|
||||
});
|
||||
|
||||
const connector = connect(mapState, {
|
||||
userLoggedIn,
|
||||
saveSessionResponse,
|
||||
});
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
loggedIn: boolean;
|
||||
Component: any;
|
||||
userLoggedIn: typeof userLoggedIn;
|
||||
saveSessionResponse: typeof saveSessionResponse;
|
||||
}
|
||||
|
||||
const ProtectedRoute = ({
|
||||
Component,
|
||||
loggedIn,
|
||||
userLoggedIn,
|
||||
saveSessionResponse,
|
||||
}: ProtectedRouteProps) => {
|
||||
const [sessionLoading, setSessionLoading] = useState<boolean>(true);
|
||||
useEffect(() => {
|
||||
api
|
||||
.invoke("GET", `/api/v1/session`)
|
||||
.then((res) => {
|
||||
saveSessionResponse(res);
|
||||
userLoggedIn(true);
|
||||
setSessionLoading(false);
|
||||
})
|
||||
.catch(() => setSessionLoading(false));
|
||||
}, [saveSessionResponse]);
|
||||
|
||||
// if we still trying to retrieve user session render nothing
|
||||
if (sessionLoading) {
|
||||
return null;
|
||||
}
|
||||
// redirect user to the right page based on session status
|
||||
return loggedIn ? <Component /> : <Redirect to={{ pathname: "/login" }} />;
|
||||
};
|
||||
|
||||
export default connector(ProtectedRoute);
|
||||
@@ -15,65 +15,24 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import React from "react";
|
||||
import { Redirect, Route, Router, Switch } from "react-router-dom";
|
||||
import { Route, Router, Switch } from "react-router-dom";
|
||||
import history from "./history";
|
||||
import Login from "./screens/LoginPage/LoginPage";
|
||||
import Console from "./screens/Console/Console";
|
||||
import storage from "local-storage-fallback";
|
||||
import { connect } from "react-redux";
|
||||
import { AppState } from "./store";
|
||||
import { userLoggedIn } from "./actions";
|
||||
import LoginCallback from "./screens/LoginPage/LoginCallback";
|
||||
import { hot } from "react-hot-loader/root";
|
||||
import ProtectedRoute from "./ProtectedRoutes";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
loggedIn: boolean;
|
||||
component: any;
|
||||
}
|
||||
|
||||
export class ProtectedRoute extends React.Component<ProtectedRouteProps> {
|
||||
render() {
|
||||
const Component = this.props.component;
|
||||
return this.props.loggedIn ? (
|
||||
<Component />
|
||||
) : (
|
||||
<Redirect to={{ pathname: "/login" }} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isLoggedIn = () => {
|
||||
const Routes = () => {
|
||||
return (
|
||||
storage.getItem("token") !== undefined &&
|
||||
storage.getItem("token") !== null &&
|
||||
storage.getItem("token") !== ""
|
||||
<Router history={history}>
|
||||
<Switch>
|
||||
<Route exact path="/oauth_callback" component={LoginCallback} />
|
||||
<Route exact path="/login" component={Login} />
|
||||
<ProtectedRoute Component={Console} />
|
||||
</Switch>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
const mapState = (state: AppState) => ({
|
||||
loggedIn: state.system.loggedIn,
|
||||
});
|
||||
|
||||
const connector = connect(mapState, { userLoggedIn });
|
||||
|
||||
interface RoutesProps {
|
||||
loggedIn: boolean;
|
||||
userLoggedIn: typeof userLoggedIn;
|
||||
}
|
||||
|
||||
class Routes extends React.Component<RoutesProps> {
|
||||
render() {
|
||||
const loggedIn = isLoggedIn();
|
||||
return (
|
||||
<Router history={history}>
|
||||
<Switch>
|
||||
<Route exact path="/oauth_callback" component={LoginCallback} />
|
||||
<Route exact path="/login" component={Login} />
|
||||
<ProtectedRoute component={Console} loggedIn={loggedIn} />
|
||||
</Switch>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(connector(Routes));
|
||||
export default hot(Routes);
|
||||
|
||||
@@ -14,16 +14,13 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import storage from "local-storage-fallback";
|
||||
import request from "superagent";
|
||||
import get from "lodash/get";
|
||||
import { clearSession } from "../utils";
|
||||
|
||||
export class API {
|
||||
invoke(method: string, url: string, data?: object) {
|
||||
const token: string = storage.getItem("token")!;
|
||||
return request(method, url)
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
.send(data)
|
||||
.then((res) => res.body)
|
||||
.catch((err) => {
|
||||
|
||||
@@ -62,17 +62,12 @@ export const deleteCookie = (name: string) => {
|
||||
document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:01 GMT;";
|
||||
};
|
||||
|
||||
export const setSession = (token: string) => {
|
||||
setCookie("token", token);
|
||||
storage.setItem("token", token);
|
||||
};
|
||||
|
||||
export const clearSession = () => {
|
||||
storage.removeItem("token");
|
||||
deleteCookie("token");
|
||||
};
|
||||
|
||||
// timeFromdate gets time string from date input
|
||||
// timeFromDate gets time string from date input
|
||||
export const timeFromDate = (d: Date) => {
|
||||
let h = d.getHours() < 10 ? `0${d.getHours()}` : `${d.getHours()}`;
|
||||
let m = d.getMinutes() < 10 ? `0${d.getMinutes()}` : `${d.getMinutes()}`;
|
||||
|
||||
@@ -227,7 +227,6 @@ const ListObjects = ({
|
||||
let xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open("POST", uploadUrl, true);
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||
|
||||
xhr.withCredentials = false;
|
||||
xhr.onload = function (event) {
|
||||
@@ -276,7 +275,6 @@ const ListObjects = ({
|
||||
`/api/v1/buckets/${bucketName}/objects/download?prefix=${objectName}`,
|
||||
true
|
||||
);
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||
xhr.responseType = "blob";
|
||||
|
||||
xhr.onload = function (e) {
|
||||
|
||||
@@ -46,9 +46,7 @@ import Heal from "./Heal/Heal";
|
||||
import Watch from "./Watch/Watch";
|
||||
import ListTenants from "./Tenants/ListTenants/ListTenants";
|
||||
import { ISessionResponse } from "./types";
|
||||
import { saveSessionResponse } from "./actions";
|
||||
import TenantDetails from "./Tenants/TenantDetails/TenantDetails";
|
||||
import { clearSession } from "../../common/utils";
|
||||
import ObjectBrowser from "./ObjectBrowser/ObjectBrowser";
|
||||
import ListObjects from "./Buckets/ListBuckets/Objects/ListObjects/ListObjects";
|
||||
import License from "./License/License";
|
||||
@@ -160,7 +158,6 @@ interface IConsoleProps {
|
||||
setMenuOpen: typeof setMenuOpen;
|
||||
serverNeedsRestart: typeof serverNeedsRestart;
|
||||
serverIsLoading: typeof serverIsLoading;
|
||||
saveSessionResponse: typeof saveSessionResponse;
|
||||
session: ISessionResponse;
|
||||
}
|
||||
|
||||
@@ -171,24 +168,8 @@ const Console = ({
|
||||
isServerLoading,
|
||||
serverNeedsRestart,
|
||||
serverIsLoading,
|
||||
saveSessionResponse,
|
||||
session,
|
||||
}: IConsoleProps) => {
|
||||
useEffect(() => {
|
||||
api
|
||||
.invoke("GET", `/api/v1/session`)
|
||||
.then((res) => {
|
||||
saveSessionResponse(res);
|
||||
})
|
||||
.catch(() => {
|
||||
// if server returns 401 for /api/v1/session call invoke function will internally call clearSession()
|
||||
// and redirecto to window.location.href = "/"; and this code will be not reached
|
||||
// in case that not happen we clear session here and redirect as well
|
||||
clearSession();
|
||||
window.location.href = "/login";
|
||||
});
|
||||
}, [saveSessionResponse]);
|
||||
|
||||
const restartServer = () => {
|
||||
serverIsLoading(true);
|
||||
api
|
||||
@@ -375,7 +356,6 @@ const connector = connect(mapState, {
|
||||
setMenuOpen,
|
||||
serverNeedsRestart,
|
||||
serverIsLoading,
|
||||
saveSessionResponse,
|
||||
});
|
||||
|
||||
export default connector(withStyles(styles)(Console));
|
||||
|
||||
@@ -38,7 +38,6 @@ import { SystemState } from "../../types";
|
||||
import { userLoggedIn } from "../../actions";
|
||||
import api from "../../common/api";
|
||||
import { ILoginDetails, loginStrategyType } from "./types";
|
||||
import { setSession } from "../../common/utils";
|
||||
import history from "../../history";
|
||||
import { OutlinedInputProps } from "@material-ui/core/OutlinedInput";
|
||||
|
||||
@@ -225,10 +224,7 @@ const Login = ({ classes, userLoggedIn }: ILoginProps) => {
|
||||
.send(loginStrategyPayload[loginStrategy.loginStrategy])
|
||||
.then((res: any) => {
|
||||
const bodyResponse = res.body;
|
||||
if (bodyResponse.sessionId) {
|
||||
// store the jwt token
|
||||
setSession(bodyResponse.sessionId);
|
||||
} else if (bodyResponse.error) {
|
||||
if (bodyResponse.error) {
|
||||
setLoginSending(false);
|
||||
// throw will be moved to catch block once bad login returns 403
|
||||
throw bodyResponse.error;
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/pkg/certs"
|
||||
"github.com/minio/minio/pkg/env"
|
||||
@@ -41,6 +42,8 @@ var TLSPort = "9443"
|
||||
// TLSRedirect console tls redirect rule
|
||||
var TLSRedirect = "off"
|
||||
|
||||
var SessionDuration = 45 * time.Minute
|
||||
|
||||
func getAccessKey() string {
|
||||
return env.Get(ConsoleAccessKey, "minioadmin")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ package restapi
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -168,8 +169,10 @@ func setupMiddlewares(handler http.Handler) http.Handler {
|
||||
// The middleware configuration happens before anything, this middleware also applies to serving the swagger.json document.
|
||||
// So this is a good place to plug in a panic handling middleware, logging and metrics
|
||||
func setupGlobalMiddleware(handler http.Handler) http.Handler {
|
||||
// handle cookie or authorization header for session
|
||||
next := AuthenticationMiddleware(handler)
|
||||
// serve static files
|
||||
next := FileServerMiddleware(handler)
|
||||
next = FileServerMiddleware(next)
|
||||
// Secure middleware, this middleware wrap all the previous handlers and add
|
||||
// HTTP security headers
|
||||
secureOptions := secure.Options{
|
||||
@@ -200,6 +203,31 @@ func setupGlobalMiddleware(handler http.Handler) http.Handler {
|
||||
return app
|
||||
}
|
||||
|
||||
func AuthenticationMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// prioritize authorization header and skip
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
tokenCookie, err := r.Cookie("token")
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
currentTime := time.Now()
|
||||
if tokenCookie.Expires.After(currentTime) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
token := tokenCookie.Value
|
||||
if token != "" {
|
||||
r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// FileServerMiddleware serves files from the static folder
|
||||
func FileServerMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -96,7 +96,7 @@ func Test_ResourceQuota(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Handle error while fetching storage quota elementss",
|
||||
name: "Handle error while fetching storage quota elements",
|
||||
args: args{
|
||||
ctx: ctx,
|
||||
client: kClient,
|
||||
|
||||
@@ -19,7 +19,9 @@ package restapi
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-openapi/runtime"
|
||||
"github.com/go-openapi/runtime/middleware"
|
||||
"github.com/minio/console/models"
|
||||
"github.com/minio/console/pkg/acl"
|
||||
@@ -45,21 +47,36 @@ func registerLoginHandlers(api *operations.ConsoleAPI) {
|
||||
if err != nil {
|
||||
return user_api.NewLoginDefault(int(err.Code)).WithPayload(err)
|
||||
}
|
||||
return user_api.NewLoginCreated().WithPayload(loginResponse)
|
||||
// Custom response writer to set the session cookies
|
||||
return middleware.ResponderFunc(func(w http.ResponseWriter, p runtime.Producer) {
|
||||
cookie := NewSessionCookieForConsole(loginResponse.SessionID)
|
||||
http.SetCookie(w, &cookie)
|
||||
user_api.NewLoginCreated().WithPayload(loginResponse).WriteResponse(w, p)
|
||||
})
|
||||
})
|
||||
api.UserAPILoginOauth2AuthHandler = user_api.LoginOauth2AuthHandlerFunc(func(params user_api.LoginOauth2AuthParams) middleware.Responder {
|
||||
loginResponse, err := getLoginOauth2AuthResponse(params.Body)
|
||||
if err != nil {
|
||||
return user_api.NewLoginOauth2AuthDefault(int(err.Code)).WithPayload(err)
|
||||
}
|
||||
return user_api.NewLoginOauth2AuthCreated().WithPayload(loginResponse)
|
||||
// Custom response writer to set the session cookies
|
||||
return middleware.ResponderFunc(func(w http.ResponseWriter, p runtime.Producer) {
|
||||
cookie := NewSessionCookieForConsole(loginResponse.SessionID)
|
||||
http.SetCookie(w, &cookie)
|
||||
user_api.NewLoginOauth2AuthCreated().WithPayload(loginResponse).WriteResponse(w, p)
|
||||
})
|
||||
})
|
||||
api.UserAPILoginOperatorHandler = user_api.LoginOperatorHandlerFunc(func(params user_api.LoginOperatorParams) middleware.Responder {
|
||||
loginResponse, err := getLoginOperatorResponse(params.Body)
|
||||
if err != nil {
|
||||
return user_api.NewLoginOperatorDefault(int(err.Code)).WithPayload(err)
|
||||
}
|
||||
return user_api.NewLoginOperatorCreated().WithPayload(loginResponse)
|
||||
// Custom response writer to set the session cookies
|
||||
return middleware.ResponderFunc(func(w http.ResponseWriter, p runtime.Producer) {
|
||||
cookie := NewSessionCookieForConsole(loginResponse.SessionID)
|
||||
http.SetCookie(w, &cookie)
|
||||
user_api.NewLoginOperatorCreated().WithPayload(loginResponse).WriteResponse(w, p)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-openapi/runtime"
|
||||
"github.com/go-openapi/runtime/middleware"
|
||||
"github.com/minio/console/models"
|
||||
"github.com/minio/console/restapi/operations"
|
||||
@@ -27,7 +30,14 @@ func registerLogoutHandlers(api *operations.ConsoleAPI) {
|
||||
// logout from console
|
||||
api.UserAPILogoutHandler = user_api.LogoutHandlerFunc(func(params user_api.LogoutParams, session *models.Principal) middleware.Responder {
|
||||
getLogoutResponse(session)
|
||||
return user_api.NewLogoutOK()
|
||||
// Custom response writer to expire the session cookies
|
||||
return middleware.ResponderFunc(func(w http.ResponseWriter, p runtime.Producer) {
|
||||
expiredCookie := ExpireSessionCookie()
|
||||
// this will tell the browser to clear the cookie and invalidate user session
|
||||
// additionally we are deleting the cookie from the client side
|
||||
http.SetCookie(w, &expiredCookie)
|
||||
user_api.NewLogoutOK().WriteResponse(w, p)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@ package restapi
|
||||
import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Do not use:
|
||||
@@ -102,3 +104,37 @@ func FileExists(filename string) bool {
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
func NewSessionCookieForConsole(token string) http.Cookie {
|
||||
expiration := time.Now().Add(SessionDuration)
|
||||
|
||||
return http.Cookie{
|
||||
Path: "/api", // browser will send cookie only for HTTP request under api path
|
||||
Name: "token",
|
||||
Value: token,
|
||||
MaxAge: int(SessionDuration.Seconds()), // 45 minutes
|
||||
Expires: expiration,
|
||||
HttpOnly: true,
|
||||
// if len(GlobalPublicCerts) > 0 is true, that means Console is running with TLS enable and the browser
|
||||
// should not leak any cookie if we access the site using HTTP
|
||||
Secure: len(GlobalPublicCerts) > 0,
|
||||
// read more: https://web.dev/samesite-cookies-explained/
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
func ExpireSessionCookie() http.Cookie {
|
||||
return http.Cookie{
|
||||
Path: "/api", // browser will send cookie only for HTTP request under api path
|
||||
Name: "token",
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
Expires: time.Now().Add(-100 * time.Hour),
|
||||
HttpOnly: true,
|
||||
// if len(GlobalPublicCerts) > 0 is true, that means Console is running with TLS enable and the browser
|
||||
// should not leak any cookie if we access the site using HTTP
|
||||
Secure: len(GlobalPublicCerts) > 0,
|
||||
// read more: https://web.dev/samesite-cookies-explained/
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user