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:
Lenin Alevski
2020-11-13 16:26:03 -08:00
committed by GitHub
parent 419e94ccec
commit be569aee4f
14 changed files with 330 additions and 197 deletions

File diff suppressed because one or more lines are too long

View 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);

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -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()}`;

View File

@@ -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) {

View File

@@ -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));

View File

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

View File

@@ -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")
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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)
})
})
}

View File

@@ -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)
})
})
}

View File

@@ -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,
}
}