diff --git a/.github/workflows/jobs.yaml b/.github/workflows/jobs.yaml index 5544d5edd..64d443c94 100644 --- a/.github/workflows/jobs.yaml +++ b/.github/workflows/jobs.yaml @@ -276,7 +276,7 @@ jobs: semgrep --config semgrep.yaml $(pwd)/portal-ui --error no-warnings-and-make-assets: - name: "React Code Has No Warning & Prettified and then Make Assets" + name: "React Code Has No Warnings & is Prettified, then Make Assets" runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/models/login_request.go b/models/login_request.go index 696785edc..a9cd8327a 100644 --- a/models/login_request.go +++ b/models/login_request.go @@ -28,7 +28,6 @@ import ( "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" - "github.com/go-openapi/validate" ) // LoginRequest login request @@ -37,48 +36,32 @@ import ( type LoginRequest struct { // access key - // Required: true - AccessKey *string `json:"accessKey"` + AccessKey string `json:"accessKey,omitempty"` // features Features *LoginRequestFeatures `json:"features,omitempty"` // secret key - // Required: true - SecretKey *string `json:"secretKey"` + SecretKey string `json:"secretKey,omitempty"` + + // sts + Sts string `json:"sts,omitempty"` } // Validate validates this login request func (m *LoginRequest) Validate(formats strfmt.Registry) error { var res []error - if err := m.validateAccessKey(formats); err != nil { - res = append(res, err) - } - if err := m.validateFeatures(formats); err != nil { res = append(res, err) } - if err := m.validateSecretKey(formats); err != nil { - res = append(res, err) - } - if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } -func (m *LoginRequest) validateAccessKey(formats strfmt.Registry) error { - - if err := validate.Required("accessKey", "body", m.AccessKey); err != nil { - return err - } - - return nil -} - func (m *LoginRequest) validateFeatures(formats strfmt.Registry) error { if swag.IsZero(m.Features) { // not required return nil @@ -98,15 +81,6 @@ func (m *LoginRequest) validateFeatures(formats strfmt.Registry) error { return nil } -func (m *LoginRequest) validateSecretKey(formats strfmt.Registry) error { - - if err := validate.Required("secretKey", "body", m.SecretKey); err != nil { - return err - } - - return nil -} - // ContextValidate validate this login request based on the context it is used func (m *LoginRequest) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error diff --git a/models/principal.go b/models/principal.go index 761359b32..23899c1fd 100644 --- a/models/principal.go +++ b/models/principal.go @@ -48,6 +48,9 @@ type Principal struct { // hm Hm bool `json:"hm,omitempty"` + + // ob + Ob bool `json:"ob,omitempty"` } // Validate validates this principal diff --git a/operatorapi/embedded_spec.go b/operatorapi/embedded_spec.go index 2dc8f4008..dd7278418 100644 --- a/operatorapi/embedded_spec.go +++ b/operatorapi/embedded_spec.go @@ -3288,10 +3288,6 @@ func init() { }, "loginRequest": { "type": "object", - "required": [ - "accessKey", - "secretKey" - ], "properties": { "accessKey": { "type": "string" @@ -3306,6 +3302,9 @@ func init() { }, "secretKey": { "type": "string" + }, + "sts": { + "type": "string" } } }, @@ -8801,10 +8800,6 @@ func init() { }, "loginRequest": { "type": "object", - "required": [ - "accessKey", - "secretKey" - ], "properties": { "accessKey": { "type": "string" @@ -8819,6 +8814,9 @@ func init() { }, "secretKey": { "type": "string" + }, + "sts": { + "type": "string" } } }, diff --git a/pkg/auth/token.go b/pkg/auth/token.go index c287b5d2a..49f084a70 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -67,11 +67,18 @@ type TokenClaims struct { STSSessionToken string `json:"stsSessionToken,omitempty"` AccountAccessKey string `json:"accountAccessKey,omitempty"` HideMenu bool `json:"hm,omitempty"` + ObjectBrowser bool `json:"ob,omitempty"` +} + +// STSClaims claims struct for STS Token +type STSClaims struct { + AccessKey string `json:"accessKey,omitempty"` } // SessionFeatures represents features stored in the session type SessionFeatures struct { - HideMenu bool + HideMenu bool + ObjectBrowser bool } // SessionTokenAuthenticate takes a session token, decode it, extract claims and validate the signature @@ -115,6 +122,7 @@ func NewEncryptedTokenForClient(credentials *credentials.Value, accountAccessKey } if features != nil { tokenClaims.HideMenu = features.HideMenu + tokenClaims.ObjectBrowser = features.ObjectBrowser } encryptedClaims, err := encryptClaims(tokenClaims) if err != nil { diff --git a/portal-ui/src/screens/Console/Buckets/BucketDetails/BrowserHandler.tsx b/portal-ui/src/screens/Console/Buckets/BucketDetails/BrowserHandler.tsx index 1cbb18c53..7389673d7 100644 --- a/portal-ui/src/screens/Console/Buckets/BucketDetails/BrowserHandler.tsx +++ b/portal-ui/src/screens/Console/Buckets/BucketDetails/BrowserHandler.tsx @@ -36,13 +36,15 @@ import { IAM_ROLES, IAM_SCOPES, } from "../../../../common/SecureComponent/permissions"; -import SearchBox from "../../Common/SearchBox"; import BackLink from "../../../../common/BackLink"; import { setSearchObjects, setSearchVersions, setVersionsModeEnabled, } from "../../ObjectBrowser/objectBrowserSlice"; +import SearchBox from "../../Common/SearchBox"; +import { selFeatures } from "../../consoleSlice"; +import { LoginMinIOLogo } from "../../../../icons"; const styles = (theme: Theme) => createStyles({ @@ -67,9 +69,13 @@ const BrowserHandler = () => { (state: AppState) => state.objectBrowser.searchVersions ); + const features = useSelector(selFeatures); + const bucketName = params.bucketName || ""; const internalPaths = get(params, "subpaths", ""); + const obOnly = !!features?.includes("object-browser-only"); + useEffect(() => { dispatch(setVersionsModeEnabled({ status: false })); }, [internalPaths, dispatch]); @@ -78,59 +84,79 @@ const BrowserHandler = () => { navigate(`/buckets/${bucketName}/admin`); }; + const searchBar = ( + + {!versionsMode ? ( + + { + dispatch(setSearchObjects(value)); + }} + value={searchObjects} + /> + + ) : ( + + { + dispatch(setSearchVersions(value)); + }} + value={searchVersions} + /> + + )} + + ); + return ( - } - actions={ - - - - - - - - } - middleComponent={ - - {!versionsMode ? ( - - { - dispatch(setSearchObjects(value)); - }} - value={searchObjects} - /> - - ) : ( - - { - dispatch(setSearchVersions(value)); - }} - value={searchVersions} - /> - - )} - - } - /> + {!obOnly ? ( + } + actions={ + + + + + + + + } + middleComponent={searchBar} + /> + ) : ( + + + + + + {searchBar} + + + )} diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/BucketListItem.tsx b/portal-ui/src/screens/Console/Buckets/ListBuckets/BucketListItem.tsx index 5665e810d..4eec912c9 100644 --- a/portal-ui/src/screens/Console/Buckets/ListBuckets/BucketListItem.tsx +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/BucketListItem.tsx @@ -163,6 +163,7 @@ interface IBucketListItem { onSelect: (e: React.ChangeEvent) => void; selected: boolean; bulkSelect: boolean; + noManage?: boolean; } const BucketListItem = ({ @@ -171,6 +172,7 @@ const BucketListItem = ({ onSelect, selected, bulkSelect, + noManage = false, }: IBucketListItem) => { const usage = niceBytes(`${bucket.size}` || "0"); const usageScalar = usage.split(" ")[0]; @@ -236,24 +238,26 @@ const BucketListItem = ({ - - - {}} - text={"Manage"} - icon={} - color={"primary"} - variant={"outlined"} - /> - - + + {}} + text={"Manage"} + icon={} + color={"primary"} + variant={"outlined"} + /> + + + )} createStyles({ @@ -98,9 +101,11 @@ const ListBuckets = ({ classes }: IListBucketsProps) => { const [replicationModalOpen, setReplicationModalOpen] = useState(false); const [lifecycleModalOpen, setLifecycleModalOpen] = useState(false); - const [bulkSelect, setBulkSelect] = useState(false); + const features = useSelector(selFeatures); + const obOnly = !!features?.includes("object-browser-only"); + useEffect(() => { if (loading) { const fetchRecords = () => { @@ -172,6 +177,7 @@ const ListBuckets = ({ classes }: IListBucketsProps) => { onSelect={selectListBuckets} selected={selectedBuckets.includes(bucket.name)} bulkSelect={bulkSelect} + noManage={obOnly} /> ); } @@ -209,9 +215,16 @@ const ListBuckets = ({ classes }: IListBucketsProps) => { open={lifecycleModalOpen} /> )} - + {!obOnly && } + {obOnly && ( + + + + )} { alignItems={"center"} justifyContent={"flex-end"} > - { - setBulkSelect(!bulkSelect); - setSelectedBuckets([]); - }} - text={""} - icon={} - color={"primary"} - variant={bulkSelect ? "contained" : "outlined"} - /> + {!obOnly && ( + + { + setBulkSelect(!bulkSelect); + setSelectedBuckets([]); + }} + text={""} + icon={} + color={"primary"} + variant={bulkSelect ? "contained" : "outlined"} + /> - {bulkSelect && ( - } - color={"primary"} - variant={"outlined"} - /> + {bulkSelect && ( + } + color={"primary"} + variant={"outlined"} + /> + )} + + { + setLifecycleModalOpen(true); + }} + text={""} + icon={} + disabled={selectedBuckets.length === 0} + color={"primary"} + variant={"outlined"} + /> + + { + setReplicationModalOpen(true); + }} + text={""} + icon={} + disabled={selectedBuckets.length === 0} + color={"primary"} + variant={"outlined"} + /> + )} - { - setLifecycleModalOpen(true); - }} - text={""} - icon={} - disabled={selectedBuckets.length === 0} - color={"primary"} - variant={"outlined"} - /> - - { - setReplicationModalOpen(true); - }} - text={""} - icon={} - disabled={selectedBuckets.length === 0} - color={"primary"} - variant={"outlined"} - /> - { @@ -290,17 +307,19 @@ const ListBuckets = ({ classes }: IListBucketsProps) => { variant={"outlined"} /> - { - navigate(IAM_PAGES.ADD_BUCKETS); - }} - text={"Create Bucket"} - icon={} - color={"primary"} - variant={"contained"} - disabled={!canCreateBucket} - /> + {!obOnly && ( + { + navigate(IAM_PAGES.ADD_BUCKETS); + }} + text={"Create Bucket"} + icon={} + color={"primary"} + variant={"contained"} + disabled={!canCreateBucket} + /> + )} diff --git a/portal-ui/src/screens/Console/Console.tsx b/portal-ui/src/screens/Console/Console.tsx index 15700e443..4f17096c2 100644 --- a/portal-ui/src/screens/Console/Console.tsx +++ b/portal-ui/src/screens/Console/Console.tsx @@ -203,6 +203,8 @@ const Console = ({ classes }: IConsoleProps) => { const [openSnackbar, setOpenSnackbar] = useState(false); const ldapIsEnabled = (features && features.includes("ldap-idp")) || false; + const obOnly = !!features?.includes("object-browser-only"); + const restartServer = () => { dispatch(serverIsLoading(true)); api @@ -461,16 +463,17 @@ const Console = ({ classes }: IConsoleProps) => { const allowedRoutes = ( operatorMode ? operatorConsoleRoutes : consoleAdminRoutes - ).filter( - (route: any) => - (route.forceDisplay || - (route.customPermissionFnc - ? route.customPermissionFnc() - : hasPermission( - CONSOLE_UI_RESOURCE, - IAM_PAGES_PERMISSIONS[route.path] - ))) && - !route.fsHidden + ).filter((route: any) => + obOnly + ? route.path.includes("buckets") + : (route.forceDisplay || + (route.customPermissionFnc + ? route.customPermissionFnc() + : hasPermission( + CONSOLE_UI_RESOURCE, + IAM_PAGES_PERMISSIONS[route.path] + ))) && + !route.fsHidden ); const closeSnackBar = () => { @@ -494,6 +497,8 @@ const Console = ({ classes }: IConsoleProps) => { hideMenu = true; } else if (pathname.endsWith("/hop")) { hideMenu = true; + } else if (obOnly) { + hideMenu = true; } return ( diff --git a/portal-ui/src/screens/LoginPage/LoginField.tsx b/portal-ui/src/screens/LoginPage/LoginField.tsx new file mode 100644 index 000000000..70647fdc3 --- /dev/null +++ b/portal-ui/src/screens/LoginPage/LoginField.tsx @@ -0,0 +1,68 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 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 . + +import makeStyles from "@mui/styles/makeStyles"; +import { Theme } from "@mui/material/styles"; +import createStyles from "@mui/styles/createStyles"; +import { TextFieldProps } from "@mui/material"; +import TextField from "@mui/material/TextField"; +import React from "react"; + +const inputStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + "& .MuiOutlinedInput-root": { + paddingLeft: 0, + "& svg": { + marginLeft: 4, + height: 14, + color: theme.palette.primary.main, + }, + "& input": { + padding: 10, + fontSize: 14, + paddingLeft: 0, + "&::placeholder": { + fontSize: 12, + }, + "@media (max-width: 900px)": { + padding: 10, + }, + }, + "& fieldset": {}, + + "& fieldset:hover": { + borderBottom: "2px solid #000000", + borderRadius: 0, + }, + }, + }, + }) +); + +export const LoginField = (props: TextFieldProps) => { + const classes = inputStyles(); + + return ( + + ); +}; diff --git a/portal-ui/src/screens/LoginPage/LoginPage.tsx b/portal-ui/src/screens/LoginPage/LoginPage.tsx index a2f0fd01d..988487018 100644 --- a/portal-ui/src/screens/LoginPage/LoginPage.tsx +++ b/portal-ui/src/screens/LoginPage/LoginPage.tsx @@ -14,25 +14,16 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { - Box, - InputAdornment, - LinearProgress, - TextFieldProps, -} from "@mui/material"; +import { Box, InputAdornment, LinearProgress } from "@mui/material"; import { Theme, useTheme } from "@mui/material/styles"; import createStyles from "@mui/styles/createStyles"; import makeStyles from "@mui/styles/makeStyles"; -import withStyles from "@mui/styles/withStyles"; import Button from "@mui/material/Button"; -import TextField from "@mui/material/TextField"; import Grid from "@mui/material/Grid"; -import { ILoginDetails, loginStrategyType } from "./types"; -import { ErrorResponseHandler } from "../../common/types"; -import api from "../../common/api"; +import { loginStrategyType } from "./types"; import RefreshIcon from "../../icons/RefreshIcon"; import MainError from "../Console/Common/MainError/MainError"; import { @@ -45,20 +36,22 @@ import { } from "../../icons"; import { spacingUtils } from "../Console/Common/FormComponents/common/styleLibrary"; import CssBaseline from "@mui/material/CssBaseline"; -import LockFilledIcon from "../../icons/LockFilledIcon"; -import UserFilledIcon from "../../icons/UsersFilledIcon"; import { SupportMenuIcon } from "../../icons/SidebarMenus"; import GithubIcon from "../../icons/GithubIcon"; import clsx from "clsx"; import Loader from "../Console/Common/Loader/Loader"; +import { AppState, useAppDispatch } from "../../store"; +import { useSelector } from "react-redux"; import { - setErrorSnackMessage, - userLogged, - showMarketplace, -} from "../../systemSlice"; -import { useAppDispatch } from "../../store"; + doLoginAsync, + getFetchConfigurationAsync, + getVersionAsync, +} from "./loginThunks"; +import { resetForm, setJwt } from "./loginSlice"; +import StrategyForm from "./StrategyForm"; +import { LoginField } from "./LoginField"; -const styles = (theme: Theme) => +const useStyles = makeStyles((theme: Theme) => createStyles({ root: { position: "absolute", @@ -78,18 +71,6 @@ const styles = (theme: Theme) => boxShadow: "none", padding: "16px 30px", }, - learnMore: { - textAlign: "center", - fontSize: 10, - "& a": { - color: "#2781B0", - }, - "& .min-icon": { - marginLeft: 12, - marginTop: 2, - width: 10, - }, - }, separator: { marginLeft: 8, marginRight: 8, @@ -227,298 +208,89 @@ const styles = (theme: Theme) => }, }, ...spacingUtils, - }); - -const inputStyles = makeStyles((theme: Theme) => - createStyles({ - root: { - "& .MuiOutlinedInput-root": { - paddingLeft: 0, - "& svg": { - marginLeft: 4, - height: 14, - color: theme.palette.primary.main, - }, - "& input": { - padding: 10, - fontSize: 14, - paddingLeft: 0, - "&::placeholder": { - fontSize: 12, - }, - "@media (max-width: 900px)": { - padding: 10, - }, - }, - "& fieldset": {}, - - "& fieldset:hover": { - borderBottom: "2px solid #000000", - borderRadius: 0, - }, - }, - }, }) ); -function LoginField(props: TextFieldProps) { - const classes = inputStyles(); - - return ( - - ); -} - -// The inferred type will look like: -// {isOn: boolean, toggleOn: () => void} - -interface ILoginProps { - classes: any; -} - -interface LoginStrategyRoutes { +export interface LoginStrategyRoutes { [key: string]: string; } -interface LoginStrategyPayload { +export interface LoginStrategyPayload { [key: string]: any; } -const Login = ({ classes }: ILoginProps) => { +export const loginStrategyEndpoints: LoginStrategyRoutes = { + form: "/api/v1/login", + "service-account": "/api/v1/login/operator", +}; + +export const getTargetPath = () => { + let targetPath = "/"; + if ( + localStorage.getItem("redirect-path") && + localStorage.getItem("redirect-path") !== "" + ) { + targetPath = `${localStorage.getItem("redirect-path")}`; + localStorage.setItem("redirect-path", ""); + } + return targetPath; +}; + +const Login = () => { const dispatch = useAppDispatch(); const navigate = useNavigate(); + const classes = useStyles(); - const [accessKey, setAccessKey] = useState(""); - const [jwt, setJwt] = useState(""); - const [secretKey, setSecretKey] = useState(""); - const [loginStrategy, setLoginStrategy] = useState({ - loginStrategy: loginStrategyType.unknown, - redirect: "", - }); - const [loginSending, setLoginSending] = useState(false); - const [loadingFetchConfiguration, setLoadingFetchConfiguration] = - useState(true); + const jwt = useSelector((state: AppState) => state.login.jwt); + const loginStrategy = useSelector( + (state: AppState) => state.login.loginStrategy + ); + const loginSending = useSelector( + (state: AppState) => state.login.loginSending + ); + const loadingFetchConfiguration = useSelector( + (state: AppState) => state.login.loadingFetchConfiguration + ); - const [latestMinIOVersion, setLatestMinIOVersion] = useState(""); - const [loadingVersion, setLoadingVersion] = useState(true); + const latestMinIOVersion = useSelector( + (state: AppState) => state.login.latestMinIOVersion + ); + const loadingVersion = useSelector( + (state: AppState) => state.login.loadingVersion + ); + const navigateTo = useSelector((state: AppState) => state.login.navigateTo); const isOperator = loginStrategy.loginStrategy === loginStrategyType.serviceAccount || loginStrategy.loginStrategy === loginStrategyType.redirectServiceAccount; - const loginStrategyEndpoints: LoginStrategyRoutes = { - form: "/api/v1/login", - "service-account": "/api/v1/login/operator", - }; - const loginStrategyPayload: LoginStrategyPayload = { - form: { accessKey, secretKey }, - "service-account": { jwt }, - }; - - const fetchConfiguration = () => { - setLoadingFetchConfiguration(true); - }; - - const getTargetPath = () => { - let targetPath = "/"; - if ( - localStorage.getItem("redirect-path") && - localStorage.getItem("redirect-path") !== "" - ) { - targetPath = `${localStorage.getItem("redirect-path")}`; - localStorage.setItem("redirect-path", ""); - } - return targetPath; - }; - - const redirectAfterLogin = () => { - navigate(getTargetPath()); - }; - - const redirectToMarketplace = () => { - api - .invoke("GET", "/api/v1/mp-integration/") - .then((res: any) => { - redirectAfterLogin(); // Email already set, continue with normal flow - }) - .catch((err: ErrorResponseHandler) => { - if (err.statusCode === 404) { - dispatch(showMarketplace(true)); - navigate("/marketplace"); - } else { - // Unexpected error, continue with normal flow - redirectAfterLogin(); - } - }); - }; + if (navigateTo !== "") { + navigate(navigateTo); + dispatch(resetForm()); + } const formSubmit = (e: React.FormEvent) => { e.preventDefault(); - setLoginSending(true); - api - .invoke( - "POST", - loginStrategyEndpoints[loginStrategy.loginStrategy] || "/api/v1/login", - loginStrategyPayload[loginStrategy.loginStrategy] - ) - .then(() => { - // We set the state in redux - dispatch(userLogged(true)); - if (loginStrategy.loginStrategy === loginStrategyType.form) { - localStorage.setItem("userLoggedIn", accessKey); - } - if (isOperator) { - redirectToMarketplace(); - } else { - redirectAfterLogin(); - } - }) - .catch((err) => { - setLoginSending(false); - dispatch(setErrorSnackMessage(err)); - }); + dispatch(doLoginAsync()); }; useEffect(() => { if (loadingFetchConfiguration) { - api - .invoke("GET", "/api/v1/login") - .then((loginDetails: ILoginDetails) => { - setLoginStrategy(loginDetails); - setLoadingFetchConfiguration(false); - }) - .catch((err: ErrorResponseHandler) => { - dispatch(setErrorSnackMessage(err)); - setLoadingFetchConfiguration(false); - }); + dispatch(getFetchConfigurationAsync()); } }, [loadingFetchConfiguration, dispatch]); useEffect(() => { if (loadingVersion) { - api - .invoke("GET", "/api/v1/check-version") - .then( - ({ - current_version, - latest_version, - }: { - current_version: string; - latest_version: string; - }) => { - setLatestMinIOVersion(latest_version); - setLoadingVersion(false); - } - ) - .catch((err: ErrorResponseHandler) => { - // try the operator version - api - .invoke("GET", "/api/v1/check-operator-version") - .then( - ({ - current_version, - latest_version, - }: { - current_version: string; - latest_version: string; - }) => { - setLatestMinIOVersion(latest_version); - setLoadingVersion(false); - } - ) - .catch((err: ErrorResponseHandler) => { - setLoadingVersion(false); - }); - }); + dispatch(getVersionAsync()); } - }, [loadingVersion, setLoadingVersion, setLatestMinIOVersion]); + }, [dispatch, loadingVersion]); - let loginComponent = null; + let loginComponent; switch (loginStrategy.loginStrategy) { case loginStrategyType.form: { - loginComponent = ( - -
- - - ) => - setAccessKey(e.target.value) - } - placeholder={"Username"} - name="accessKey" - autoComplete="username" - disabled={loginSending} - variant={"outlined"} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - - ) => - setSecretKey(e.target.value) - } - name="secretKey" - type="password" - id="secretKey" - autoComplete="current-password" - disabled={loginSending} - placeholder={"Password"} - variant={"outlined"} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - - - - - - {loginSending && } - -
-
- ); + loginComponent = ; break; } case loginStrategyType.redirect: @@ -553,7 +325,7 @@ const Login = ({ classes }: ILoginProps) => { id="jwt" value={jwt} onChange={(e: React.ChangeEvent) => - setJwt(e.target.value) + dispatch(setJwt(e.target.value)) } name="jwt" autoComplete="off" @@ -607,7 +379,7 @@ const Login = ({ classes }: ILoginProps) => {
+ + + {loginSending && } + + + + { + dispatch(setUseSTS(!useSTS)); + }} + style={{ + color: theme.colors.link, + font: "normal normal normal 12px/15px Lato", + textDecoration: "underline", + cursor: "pointer", + }} + > + {!useSTS && Use STS} + {useSTS && Use Credentials} + + { + dispatch(setUseSTS(!useSTS)); + }} + style={{ + color: theme.colors.link, + font: "normal normal normal 12px/15px Lato", + textDecoration: "none", + fontWeight: "bold", + paddingLeft: 4, + }} + > + ➔ + + + + + + ); +}; + +export default StrategyForm; diff --git a/portal-ui/src/screens/LoginPage/loginSlice.ts b/portal-ui/src/screens/LoginPage/loginSlice.ts new file mode 100644 index 000000000..51be7a747 --- /dev/null +++ b/portal-ui/src/screens/LoginPage/loginSlice.ts @@ -0,0 +1,135 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 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 . + +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { ILoginDetails, loginStrategyType } from "./types"; +import { + doLoginAsync, + getFetchConfigurationAsync, + getVersionAsync, +} from "./loginThunks"; + +export interface LoginState { + accessKey: string; + secretKey: string; + sts: string; + useSTS: boolean; + + jwt: string; + + loginStrategy: ILoginDetails; + + loginSending: boolean; + loadingFetchConfiguration: boolean; + + latestMinIOVersion: string; + loadingVersion: boolean; + + navigateTo: string; +} + +const initialState: LoginState = { + accessKey: "", + secretKey: "", + sts: "", + useSTS: false, + jwt: "", + loginStrategy: { + loginStrategy: loginStrategyType.unknown, + redirect: "", + }, + loginSending: false, + loadingFetchConfiguration: true, + latestMinIOVersion: "", + loadingVersion: true, + + navigateTo: "", +}; + +export const loginSlice = createSlice({ + name: "login", + initialState, + reducers: { + setAccessKey: (state, action: PayloadAction) => { + state.accessKey = action.payload; + }, + setSecretKey: (state, action: PayloadAction) => { + state.secretKey = action.payload; + }, + setUseSTS: (state, action: PayloadAction) => { + state.useSTS = action.payload; + }, + setSTS: (state, action: PayloadAction) => { + state.sts = action.payload; + }, + setJwt: (state, action: PayloadAction) => { + state.jwt = action.payload; + }, + setNavigateTo: (state, action: PayloadAction) => { + state.navigateTo = action.payload; + }, + resetForm: (state) => initialState, + }, + extraReducers: (builder) => { + builder + .addCase(getVersionAsync.pending, (state, action) => { + state.loadingVersion = true; + }) + .addCase(getVersionAsync.rejected, (state, action) => { + state.loadingVersion = false; + }) + .addCase(getVersionAsync.fulfilled, (state, action) => { + state.loadingVersion = false; + if (action.payload) { + state.latestMinIOVersion = action.payload; + } + }) + .addCase(getFetchConfigurationAsync.pending, (state, action) => { + state.loadingFetchConfiguration = true; + }) + .addCase(getFetchConfigurationAsync.rejected, (state, action) => { + state.loadingFetchConfiguration = false; + }) + .addCase(getFetchConfigurationAsync.fulfilled, (state, action) => { + state.loadingFetchConfiguration = false; + if (action.payload) { + state.loginStrategy = action.payload; + } + }) + .addCase(doLoginAsync.pending, (state, action) => { + state.loginSending = true; + }) + .addCase(doLoginAsync.rejected, (state, action) => { + state.loginSending = false; + }) + .addCase(doLoginAsync.fulfilled, (state, action) => { + state.loginSending = false; + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { + setAccessKey, + setSecretKey, + setUseSTS, + setSTS, + setJwt, + setNavigateTo, + resetForm, +} = loginSlice.actions; + +export default loginSlice.reducer; diff --git a/portal-ui/src/screens/LoginPage/loginThunks.ts b/portal-ui/src/screens/LoginPage/loginThunks.ts new file mode 100644 index 000000000..435ad723c --- /dev/null +++ b/portal-ui/src/screens/LoginPage/loginThunks.ts @@ -0,0 +1,145 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 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 . + +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { AppState } from "../../store"; +import api from "../../common/api"; +import { ErrorResponseHandler } from "../../common/types"; +import { + setErrorSnackMessage, + showMarketplace, + userLogged, +} from "../../systemSlice"; +import { ILoginDetails, loginStrategyType } from "./types"; +import { setNavigateTo } from "./loginSlice"; +import { + getTargetPath, + loginStrategyEndpoints, + LoginStrategyPayload, +} from "./LoginPage"; + +export const doLoginAsync = createAsyncThunk( + "login/doLoginAsync", + async (_, { getState, rejectWithValue, dispatch }) => { + const state = getState() as AppState; + const loginStrategy = state.login.loginStrategy; + const accessKey = state.login.accessKey; + const secretKey = state.login.secretKey; + const jwt = state.login.jwt; + const sts = state.login.sts; + const useSTS = state.login.useSTS; + + const isOperator = + loginStrategy.loginStrategy === loginStrategyType.serviceAccount || + loginStrategy.loginStrategy === loginStrategyType.redirectServiceAccount; + + let loginStrategyPayload: LoginStrategyPayload = { + form: { accessKey, secretKey }, + "service-account": { jwt }, + }; + if (useSTS) { + loginStrategyPayload = { + form: { accessKey, secretKey, sts }, + }; + } + + return api + .invoke( + "POST", + loginStrategyEndpoints[loginStrategy.loginStrategy] || "/api/v1/login", + loginStrategyPayload[loginStrategy.loginStrategy] + ) + .then(() => { + // We set the state in redux + dispatch(userLogged(true)); + if (loginStrategy.loginStrategy === loginStrategyType.form) { + localStorage.setItem("userLoggedIn", accessKey); + } + if (isOperator) { + api + .invoke("GET", "/api/v1/mp-integration/") + .then((res: any) => { + dispatch(setNavigateTo(getTargetPath())); // Email already set, continue with normal flow + }) + .catch((err: ErrorResponseHandler) => { + if (err.statusCode === 404) { + dispatch(showMarketplace(true)); + dispatch(setNavigateTo("/marketplace")); + } else { + // Unexpected error, continue with normal flow + dispatch(setNavigateTo(getTargetPath())); + } + }); + } else { + dispatch(setNavigateTo(getTargetPath())); + } + }) + .catch((err) => { + dispatch(setErrorSnackMessage(err)); + }); + } +); +export const getFetchConfigurationAsync = createAsyncThunk( + "login/getFetchConfigurationAsync", + async (_, { getState, rejectWithValue, dispatch }) => { + return api + .invoke("GET", "/api/v1/login") + .then((loginDetails: ILoginDetails) => { + return loginDetails; + }) + .catch((err: ErrorResponseHandler) => { + dispatch(setErrorSnackMessage(err)); + }); + } +); + +export const getVersionAsync = createAsyncThunk( + "login/getVersionAsync", + async (_, { getState, rejectWithValue, dispatch }) => { + return api + .invoke("GET", "/api/v1/check-version") + .then( + ({ + current_version, + latest_version, + }: { + current_version: string; + latest_version: string; + }) => { + return latest_version; + } + ) + .catch((err: ErrorResponseHandler) => { + // try the operator version + api + .invoke("GET", "/api/v1/check-operator-version") + .then( + ({ + current_version, + latest_version, + }: { + current_version: string; + latest_version: string; + }) => { + return latest_version; + } + ) + .catch((err: ErrorResponseHandler) => { + return err; + }); + }); + } +); diff --git a/portal-ui/src/store.ts b/portal-ui/src/store.ts index f2c82b81f..faebb0024 100644 --- a/portal-ui/src/store.ts +++ b/portal-ui/src/store.ts @@ -17,6 +17,7 @@ import { useDispatch } from "react-redux"; import { combineReducers, configureStore } from "@reduxjs/toolkit"; import systemReducer from "./systemSlice"; +import loginReducer from "./screens/LoginPage/loginSlice"; import traceReducer from "./screens/Console/Trace/traceSlice"; import logReducer from "./screens/Console/Logs/logsSlice"; import healthInfoReducer from "./screens/Console/HealthInfo/healthInfoSlice"; @@ -36,6 +37,7 @@ import editTenantAuditLoggingReducer from "./screens/Console/Tenants/TenantDetai const rootReducer = combineReducers({ system: systemReducer, + login: loginReducer, trace: traceReducer, logs: logReducer, watch: watchReducer, diff --git a/restapi/configure_console.go b/restapi/configure_console.go index 2720ba587..530e5c906 100644 --- a/restapi/configure_console.go +++ b/restapi/configure_console.go @@ -37,6 +37,7 @@ import ( "github.com/minio/console/pkg/logger" "github.com/minio/console/pkg/utils" + "github.com/minio/minio-go/v7/pkg/credentials" "github.com/klauspost/compress/gzhttp" @@ -93,6 +94,7 @@ func configureAPI(api *operations.ConsoleAPI) http.Handler { STSSessionToken: claims.STSSessionToken, AccountAccessKey: claims.AccountAccessKey, Hm: claims.HideMenu, + Ob: claims.ObjectBrowser, }, nil } @@ -335,6 +337,7 @@ func (w *notFoundRedirectRespWr) Write(p []byte) (int, error) { return len(p), nil // Lie that we successfully wrote it } +// handleSPA handles the serving of the React Single Page Application func handleSPA(w http.ResponseWriter, r *http.Request) { basePath := "/" // For SPA mode we will replace root base with a sub path if configured unless we received cp=y and cpb=/NEW/BASE @@ -354,6 +357,29 @@ func handleSPA(w http.ResponseWriter, r *http.Request) { return } + sts := r.URL.Query().Get("sts") + stsAccessKey := r.URL.Query().Get("sts_a") + stsSecretKey := r.URL.Query().Get("sts_s") + // if these three parameters are present we are being asked to issue a session with these values + if sts != "" && stsAccessKey != "" && stsSecretKey != "" { + creds := credentials.NewStaticV4(stsAccessKey, stsSecretKey, sts) + consoleCreds := &ConsoleCredentials{ + ConsoleCredentials: creds, + AccountAccessKey: stsAccessKey, + } + sf := &auth.SessionFeatures{} + sf.HideMenu = true + sf.ObjectBrowser = true + + sessionID, err := login(consoleCreds, sf) + if err != nil { + log.Println(err) + } else { + cookie := NewSessionCookieForConsole(*sessionID) + http.SetCookie(w, &cookie) + } + } + indexPageBytes, err := io.ReadAll(indexPage) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/restapi/consts.go b/restapi/consts.go index 7b639c663..2007caeff 100644 --- a/restapi/consts.go +++ b/restapi/consts.go @@ -51,6 +51,7 @@ const ( PrometheusExtraLabels = "CONSOLE_PROMETHEUS_EXTRA_LABELS" ConsoleLogQueryURL = "CONSOLE_LOG_QUERY_URL" ConsoleLogQueryAuthToken = "CONSOLE_LOG_QUERY_AUTH_TOKEN" + ConsoleObjectBrowserOnly = "CONSOLE_OBJECT_BROWSER_ONLY" LogSearchQueryAuthToken = "LOGSEARCH_QUERY_AUTH_TOKEN" SlashSeparator = "/" ) diff --git a/restapi/embedded_spec.go b/restapi/embedded_spec.go index 6ccac17f8..4852adcd9 100644 --- a/restapi/embedded_spec.go +++ b/restapi/embedded_spec.go @@ -5412,10 +5412,6 @@ func init() { }, "loginRequest": { "type": "object", - "required": [ - "accessKey", - "secretKey" - ], "properties": { "accessKey": { "type": "string" @@ -5430,6 +5426,9 @@ func init() { }, "secretKey": { "type": "string" + }, + "sts": { + "type": "string" } } }, @@ -5970,6 +5969,9 @@ func init() { }, "hm": { "type": "boolean" + }, + "ob": { + "type": "boolean" } } }, @@ -12631,10 +12633,6 @@ func init() { }, "loginRequest": { "type": "object", - "required": [ - "accessKey", - "secretKey" - ], "properties": { "accessKey": { "type": "string" @@ -12649,6 +12647,9 @@ func init() { }, "secretKey": { "type": "string" + }, + "sts": { + "type": "string" } } }, @@ -13189,6 +13190,9 @@ func init() { }, "hm": { "type": "boolean" + }, + "ob": { + "type": "boolean" } } }, diff --git a/restapi/user_login.go b/restapi/user_login.go index 9b046a0e1..298caa4fb 100644 --- a/restapi/user_login.go +++ b/restapi/user_login.go @@ -21,7 +21,6 @@ import ( "net/http" "github.com/minio/madmin-go" - "github.com/minio/minio-go/v7/pkg/credentials" "github.com/go-openapi/runtime" @@ -113,11 +112,23 @@ func getLoginResponse(params authApi.LoginParams) (*models.LoginResponse, *model ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() lr := params.Body - // prepare console credentials - consoleCreds, err := getConsoleCredentials(*lr.AccessKey, *lr.SecretKey) - if err != nil { - return nil, ErrorWithContext(ctx, err, ErrInvalidLogin, err) + var err error + var consoleCreds *ConsoleCredentials + // if we receive an STS we use that instead of the credentials + if lr.Sts != "" { + creds := credentials.NewStaticV4(lr.AccessKey, lr.SecretKey, lr.Sts) + consoleCreds = &ConsoleCredentials{ + ConsoleCredentials: creds, + AccountAccessKey: lr.AccessKey, + } + } else { + // prepare console credentials + consoleCreds, err = getConsoleCredentials(lr.AccessKey, lr.SecretKey) + if err != nil { + return nil, ErrorWithContext(ctx, err, ErrInvalidLogin, err) + } } + sf := &auth.SessionFeatures{} if lr.Features != nil { sf.HideMenu = lr.Features.HideMenu diff --git a/restapi/user_session.go b/restapi/user_session.go index 0b3718fda..b99863b3d 100644 --- a/restapi/user_session.go +++ b/restapi/user_session.go @@ -268,6 +268,9 @@ func getListOfEnabledFeatures(session *models.Principal) []string { if session.Hm { features = append(features, "hide-menu") } + if session.Ob { + features = append(features, "object-browser-only") + } return features } diff --git a/swagger-console.yml b/swagger-console.yml index 24ca4ece4..72dabb640 100644 --- a/swagger-console.yml +++ b/swagger-console.yml @@ -1,4 +1,3 @@ - swagger: "2.0" info: title: MinIO Console Server @@ -3677,14 +3676,13 @@ definitions: type: string loginRequest: type: object - required: - - accessKey - - secretKey properties: accessKey: type: string secretKey: type: string + sts: + type: string features: type: object properties: @@ -3709,6 +3707,8 @@ definitions: type: string hm: type: boolean + ob: + type: boolean startProfilingItem: type: object properties: diff --git a/swagger-operator.yml b/swagger-operator.yml index 59bc7bba2..87a924344 100644 --- a/swagger-operator.yml +++ b/swagger-operator.yml @@ -1394,14 +1394,13 @@ definitions: type: string loginRequest: type: object - required: - - accessKey - - secretKey properties: accessKey: type: string secretKey: type: string + sts: + type: string features: type: object properties: