Change Password support for Console (#457)

- Account change password endpoints
- Change account password modal
- Grouped account settings and service accounts
- Removed the SuperAdmin credentials from almost all places, only
  missing place is Oauth login
- Renamed service-accounts UI labels to account in Menu

Co-authored-by: Daniel Valdivia <hola@danielvaldivia.com>
This commit is contained in:
Lenin Alevski
2020-12-07 17:11:08 -06:00
committed by GitHub
parent 1ce18043d5
commit e2d86354fc
39 changed files with 1478 additions and 262 deletions

View File

@@ -0,0 +1,98 @@
// Code generated by go-swagger; DO NOT EDIT.
// 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/>.
//
package models
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the swagger generate command
import (
"github.com/go-openapi/errors"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/swag"
"github.com/go-openapi/validate"
)
// AccountChangePasswordRequest account change password request
//
// swagger:model accountChangePasswordRequest
type AccountChangePasswordRequest struct {
// current secret key
// Required: true
CurrentSecretKey *string `json:"current_secret_key"`
// new secret key
// Required: true
NewSecretKey *string `json:"new_secret_key"`
}
// Validate validates this account change password request
func (m *AccountChangePasswordRequest) Validate(formats strfmt.Registry) error {
var res []error
if err := m.validateCurrentSecretKey(formats); err != nil {
res = append(res, err)
}
if err := m.validateNewSecretKey(formats); err != nil {
res = append(res, err)
}
if len(res) > 0 {
return errors.CompositeValidationError(res...)
}
return nil
}
func (m *AccountChangePasswordRequest) validateCurrentSecretKey(formats strfmt.Registry) error {
if err := validate.Required("current_secret_key", "body", m.CurrentSecretKey); err != nil {
return err
}
return nil
}
func (m *AccountChangePasswordRequest) validateNewSecretKey(formats strfmt.Registry) error {
if err := validate.Required("new_secret_key", "body", m.NewSecretKey); err != nil {
return err
}
return nil
}
// MarshalBinary interface implementation
func (m *AccountChangePasswordRequest) MarshalBinary() ([]byte, error) {
if m == nil {
return nil, nil
}
return swag.WriteJSON(m)
}
// UnmarshalBinary interface implementation
func (m *AccountChangePasswordRequest) UnmarshalBinary(b []byte) error {
var res AccountChangePasswordRequest
if err := swag.ReadJSON(b, &res); err != nil {
return err
}
*m = res
return nil
}

View File

@@ -32,17 +32,23 @@ import (
// swagger:model principal
type Principal struct {
// access key ID
AccessKeyID string `json:"accessKeyID,omitempty"`
// s t s access key ID
STSAccessKeyID string `json:"STSAccessKeyID,omitempty"`
// s t s secret access key
STSSecretAccessKey string `json:"STSSecretAccessKey,omitempty"`
// s t s session token
STSSessionToken string `json:"STSSessionToken,omitempty"`
// account access key
AccountAccessKey string `json:"accountAccessKey,omitempty"`
// account secret key
AccountSecretKey string `json:"accountSecretKey,omitempty"`
// actions
Actions []string `json:"actions"`
// secret access key
SecretAccessKey string `json:"secretAccessKey,omitempty"`
// session token
SessionToken string `json:"sessionToken,omitempty"`
}
// Validate validates this principal

View File

@@ -31,7 +31,7 @@ var (
notifications = "/notification-endpoints"
buckets = "/buckets"
bucketsDetail = "/buckets/:bucketName"
serviceAccounts = "/service-accounts"
serviceAccounts = "/account"
tenants = "/tenants"
tenantsDetail = "/namespaces/:tenantNamespace/tenants/:tenantName"
remoteBuckets = "/remote-buckets"

View File

@@ -24,6 +24,7 @@ import (
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@@ -58,12 +59,14 @@ func IsSessionTokenValid(token string) bool {
return err == nil
}
// DecryptedClaims claims struct for decrypted credentials
type DecryptedClaims struct {
AccessKeyID string
SecretAccessKey string
SessionToken string
Actions []string
// TokenClaims claims struct for decrypted credentials
type TokenClaims struct {
STSAccessKeyID string `json:"stsAccessKeyID,omitempty"`
STSSecretAccessKey string `json:"stsSecretAccessKey,omitempty"`
STSSessionToken string `json:"stsSessionToken,omitempty"`
AccountAccessKey string `json:"accountAccessKey,omitempty"`
AccountSecretKey string `json:"accountSecretKey,omitempty"`
Actions []string `json:"actions,omitempty"`
}
// SessionTokenAuthenticate takes a session token, decode it, extract claims and validate the signature
@@ -71,12 +74,15 @@ type DecryptedClaims struct {
//
// returns claims after validation in the following format:
//
// type DecryptedClaims struct {
// AccessKeyID
// SecretAccessKey
// SessionToken
// type TokenClaims struct {
// STSAccessKeyID
// STSSecretAccessKey
// STSSessionToken
// AccountAccessKey
// AccountSecretKey
// Actions
// }
func SessionTokenAuthenticate(token string) (*DecryptedClaims, error) {
func SessionTokenAuthenticate(token string) (*TokenClaims, error) {
if token == "" {
return nil, errNoAuthToken
}
@@ -94,9 +100,16 @@ func SessionTokenAuthenticate(token string) (*DecryptedClaims, error) {
// NewEncryptedTokenForClient generates a new session token with claims based on the provided STS credentials, first
// encrypts the claims and the sign them
func NewEncryptedTokenForClient(credentials *credentials.Value, actions []string) (string, error) {
func NewEncryptedTokenForClient(credentials *credentials.Value, accountAccessKey, accountSecretKey string, actions []string) (string, error) {
if credentials != nil {
encryptedClaims, err := encryptClaims(credentials.AccessKeyID, credentials.SecretAccessKey, credentials.SessionToken, actions)
encryptedClaims, err := encryptClaims(&TokenClaims{
STSAccessKeyID: credentials.AccessKeyID,
STSSecretAccessKey: credentials.SecretAccessKey,
STSSessionToken: credentials.SessionToken,
AccountAccessKey: accountAccessKey,
AccountSecretKey: accountSecretKey,
Actions: actions,
})
if err != nil {
return "", err
}
@@ -107,8 +120,11 @@ func NewEncryptedTokenForClient(credentials *credentials.Value, actions []string
// encryptClaims() receives the STS claims, concatenate them and encrypt them using AES-GCM
// returns a base64 encoded ciphertext
func encryptClaims(accessKeyID, secretAccessKey, sessionToken string, actions []string) (string, error) {
payload := []byte(fmt.Sprintf("%s#%s#%s#%s", accessKeyID, secretAccessKey, sessionToken, strings.Join(actions, ",")))
func encryptClaims(credentials *TokenClaims) (string, error) {
payload, err := json.Marshal(credentials)
if err != nil {
return "", err
}
ciphertext, err := encrypt(payload, []byte{})
if err != nil {
log.Println(err)
@@ -117,8 +133,8 @@ func encryptClaims(accessKeyID, secretAccessKey, sessionToken string, actions []
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// decryptClaims() receives base64 encoded ciphertext, decode it, decrypt it (AES-GCM) and produces a *DecryptedClaims object
func decryptClaims(ciphertext string) (*DecryptedClaims, error) {
// decryptClaims() receives base64 encoded ciphertext, decode it, decrypt it (AES-GCM) and produces a *TokenClaims object
func decryptClaims(ciphertext string) (*TokenClaims, error) {
decoded, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
log.Println(err)
@@ -129,19 +145,13 @@ func decryptClaims(ciphertext string) (*DecryptedClaims, error) {
log.Println(err)
return nil, errClaimsFormat
}
s := strings.Split(string(plaintext), "#")
// Validate that the decrypted string has the right format "accessKeyID:secretAccessKey:sessionToken"
if len(s) != 4 {
tokenClaims := &TokenClaims{}
err = json.Unmarshal(plaintext, tokenClaims)
if err != nil {
log.Println(err)
return nil, errClaimsFormat
}
accessKeyID, secretAccessKey, sessionToken, actions := s[0], s[1], s[2], s[3]
actionsList := strings.Split(actions, ",")
return &DecryptedClaims{
AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey,
SessionToken: sessionToken,
Actions: actionsList,
}, nil
return tokenClaims, nil
}
const (
@@ -315,9 +325,11 @@ func GetClaimsFromTokenInRequest(req *http.Request) (*models.Principal, error) {
return nil, err
}
return &models.Principal{
AccessKeyID: claims.AccessKeyID,
Actions: claims.Actions,
SecretAccessKey: claims.SecretAccessKey,
SessionToken: claims.SessionToken,
STSAccessKeyID: claims.STSAccessKeyID,
Actions: claims.Actions,
STSSecretAccessKey: claims.STSSecretAccessKey,
STSSessionToken: claims.STSSessionToken,
AccountAccessKey: claims.AccountAccessKey,
AccountSecretKey: claims.AccountSecretKey,
}, nil
}

View File

@@ -36,14 +36,14 @@ func TestNewJWTWithClaimsForClient(t *testing.T) {
funcAssert := assert.New(t)
// Test-1 : NewEncryptedTokenForClient() is generated correctly without errors
function := "NewEncryptedTokenForClient()"
token, err := NewEncryptedTokenForClient(creds, []string{""})
token, err := NewEncryptedTokenForClient(creds, "", "", []string{""})
if err != nil || token == "" {
t.Errorf("Failed on %s:, error occurred: %s", function, err)
}
// saving token for future tests
goodToken = token
// Test-2 : NewEncryptedTokenForClient() throws error because of empty credentials
if _, err = NewEncryptedTokenForClient(nil, []string{""}); err != nil {
if _, err = NewEncryptedTokenForClient(nil, "", "", []string{""}); err != nil {
funcAssert.Equal("provided credentials are empty", err.Error())
}
}
@@ -56,9 +56,9 @@ func TestJWTAuthenticate(t *testing.T) {
if err != nil || claims == nil {
t.Errorf("Failed on %s:, error occurred: %s", function, err)
} else {
funcAssert.Equal(claims.AccessKeyID, creds.AccessKeyID)
funcAssert.Equal(claims.SecretAccessKey, creds.SecretAccessKey)
funcAssert.Equal(claims.SessionToken, creds.SessionToken)
funcAssert.Equal(claims.STSAccessKeyID, creds.AccessKeyID)
funcAssert.Equal(claims.STSSecretAccessKey, creds.SecretAccessKey)
funcAssert.Equal(claims.STSSessionToken, creds.SessionToken)
}
// Test-2 : SessionTokenAuthenticate() return an error because of a tampered token
if _, err := SessionTokenAuthenticate(badToken); err != nil {

File diff suppressed because one or more lines are too long

View File

@@ -36,6 +36,9 @@ import {
containerForHeader,
searchField,
} from "../Common/FormComponents/common/styleLibrary";
import Divider from "@material-ui/core/Divider";
import LockIcon from "@material-ui/icons/Lock";
import ChangePasswordModal from "./ChangePasswordModal";
const styles = (theme: Theme) =>
createStyles({
@@ -87,7 +90,7 @@ interface IServiceAccountsProps {
classes: any;
}
const ServiceAccounts = ({ classes }: IServiceAccountsProps) => {
const Account = ({ classes }: IServiceAccountsProps) => {
const [records, setRecords] = useState<string[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
@@ -102,6 +105,10 @@ const ServiceAccounts = ({ classes }: IServiceAccountsProps) => {
newServiceAccount,
setNewServiceAccount,
] = useState<NewServiceAccount | null>(null);
const [
changePasswordModalOpen,
setChangePasswordModalOpen,
] = useState<boolean>(false);
useEffect(() => {
fetchRecords();
@@ -194,9 +201,44 @@ const ServiceAccounts = ({ classes }: IServiceAccountsProps) => {
entity="Service Account"
/>
)}
<PageHeader label="Service Accounts" />
<PageHeader label="Account" />
<Grid container>
<Grid item xs={12} className={classes.container}>
<Grid item xs={12}>
<Typography variant="h5" component="h5">
Settings
</Typography>
</Grid>
<Grid item xs={12}>
<br />
</Grid>
<Grid item xs={12}>
<ChangePasswordModal
open={changePasswordModalOpen}
closeModal={() => setChangePasswordModalOpen(false)}
/>
<Button
variant="contained"
color="primary"
startIcon={<LockIcon />}
onClick={() => setChangePasswordModalOpen(true)}
>
Change Password
</Button>
</Grid>
<Grid item xs={12}>
<br />
<Divider />
<br />
</Grid>
<Grid item xs={12}>
<Typography variant="h5" component="h5">
Service Accounts
</Typography>
</Grid>
<Grid item xs={12}>
<br />
</Grid>
<Grid item xs={12} className={classes.actionsTray}>
<TextField
placeholder="Search Service Accounts"
@@ -257,4 +299,4 @@ const ServiceAccounts = ({ classes }: IServiceAccountsProps) => {
);
};
export default withStyles(styles)(ServiceAccounts);
export default withStyles(styles)(Account);

View File

@@ -0,0 +1,197 @@
// 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, { useState } from "react";
import { createStyles, Theme, withStyles } from "@material-ui/core/styles";
import ModalWrapper from "../Common/ModalWrapper/ModalWrapper";
import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import InputBoxWrapper from "../Common/FormComponents/InputBoxWrapper/InputBoxWrapper";
import { Button, LinearProgress } from "@material-ui/core";
import {
actionsTray,
containerForHeader,
modalBasic,
} from "../Common/FormComponents/common/styleLibrary";
import { ChangePasswordRequest } from "../Buckets/types";
import api from "../../../common/api";
const styles = (theme: Theme) =>
createStyles({
errorBlock: {
color: "red",
},
buttonContainer: {
textAlign: "right",
},
...actionsTray,
...modalBasic,
...containerForHeader(theme.spacing(4)),
});
interface IChangePasswordProps {
classes: any;
open: boolean;
closeModal: () => void;
}
const ChangePassword = ({
classes,
open,
closeModal,
}: IChangePasswordProps) => {
const [currentPassword, setCurrentPassword] = useState<string>("");
const [newPassword, setNewPassword] = useState<string>("");
const [reNewPassword, setReNewPassword] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const changePassword = (event: React.FormEvent) => {
event.preventDefault();
if (newPassword != reNewPassword) {
setError("New passwords don't match");
return;
}
if (loading) {
return;
}
setLoading(true);
let request: ChangePasswordRequest = {
current_secret_key: currentPassword,
new_secret_key: newPassword,
};
api
.invoke("POST", "/api/v1/account/change-password", request)
.then((res) => {
setLoading(false);
setError("");
setNewPassword("");
setReNewPassword("");
setCurrentPassword("");
closeModal();
})
.catch((err) => {
setLoading(false);
setNewPassword("");
setReNewPassword("");
setCurrentPassword("");
setError(err);
});
};
return open ? (
<ModalWrapper
title="Change Password"
modalOpen={open}
onClose={() => {
setNewPassword("");
setReNewPassword("");
setCurrentPassword("");
closeModal();
}}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<form
noValidate
autoComplete="off"
onSubmit={(e: React.FormEvent<HTMLFormElement>) => {
changePassword(e);
}}
>
<Grid container>
<Grid item xs={12} className={classes.formScrollable}>
{error !== "" && (
<Grid item xs={12}>
<Typography
component="p"
variant="body1"
className={classes.errorBlock}
>
{error}
</Typography>
</Grid>
)}
<Grid item xs={12}>
<InputBoxWrapper
id="current-password"
name="current-password"
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setCurrentPassword(event.target.value);
}}
label="Current Password"
type="password"
value={currentPassword}
/>
</Grid>
<Grid item xs={12}>
<InputBoxWrapper
id="new-password"
name="new-password"
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setNewPassword(event.target.value);
}}
label="New Password"
type="password"
value={newPassword}
/>
</Grid>
<Grid item xs={12}>
<InputBoxWrapper
id="re-new-password"
name="re-new-password"
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setReNewPassword(event.target.value);
}}
label="Type New Password Again"
type="password"
value={reNewPassword}
/>
</Grid>
</Grid>
<Grid item xs={12} className={classes.buttonContainer}>
<Button
type="submit"
variant="contained"
color="primary"
disabled={
loading ||
!(
currentPassword.length > 0 &&
newPassword.length > 0 &&
reNewPassword.length > 0
)
}
>
Save
</Button>
</Grid>
{loading && (
<Grid item xs={12}>
<LinearProgress />
</Grid>
)}
</Grid>
</form>
</ModalWrapper>
) : null;
};
export default withStyles(styles)(ChangePassword);

View File

@@ -85,6 +85,11 @@ export interface MakeBucketRequest {
quota?: QuotaRequest;
}
export interface ChangePasswordRequest {
current_secret_key: string;
new_secret_key: string;
}
export interface IRemoteBucket {
name: string;
accessKey: string;

View File

@@ -34,7 +34,7 @@ import Policies from "./Policies/Policies";
import Dashboard from "./Dashboard/Dashboard";
import Menu from "./Menu/Menu";
import api from "../../common/api";
import ServiceAccounts from "./ServiceAccounts/ServiceAccounts";
import Account from "./Account/Account";
import Users from "./Users/Users";
import Groups from "./Groups/Groups";
import ListNotificationEndpoints from "./NotificationEndopoints/ListNotificationEndpoints";
@@ -237,8 +237,12 @@ const Console = ({
path: "/configurations-list",
},
{
component: ServiceAccounts,
path: "/service-accounts",
component: Permissions,
path: "/permissions",
},
{
component: Account,
path: "/account",
},
{
component: WebhookPanel,
@@ -290,20 +294,20 @@ const Console = ({
<LinearProgress />
</React.Fragment>
) : (
<React.Fragment>
The instance needs to be restarted for configuration changes
<React.Fragment>
The instance needs to be restarted for configuration changes
to take effect.{" "}
<Button
color="secondary"
size="small"
onClick={() => {
restartServer();
}}
>
Restart
<Button
color="secondary"
size="small"
onClick={() => {
restartServer();
}}
>
Restart
</Button>
</React.Fragment>
)}
</React.Fragment>
)}
</div>
)}
<Container className={classes.container}>

View File

@@ -202,8 +202,8 @@ const Menu = ({ userLoggedIn, classes, pages }: IMenuProps) => {
group: "User",
type: "item",
component: NavLink,
to: "/service-accounts",
name: "Service Accounts",
to: "/account",
name: "Account",
icon: <ServiceAccountsIcon />,
},
{

View File

@@ -123,7 +123,7 @@ func min(x, y int64) int64 {
func getMaxAllocatableMemoryResponse(session *models.Principal, numNodes int32) (*models.MaxAllocatableMemResponse, *models.Error) {
ctx := context.Background()
client, err := cluster.K8sClient(session.SessionToken)
client, err := cluster.K8sClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}

View File

@@ -163,12 +163,12 @@ func registerTenantHandlers(api *operations.ConsoleAPI) {
// getDeleteTenantResponse gets the output of deleting a minio instance
func getDeleteTenantResponse(session *models.Principal, params admin_api.DeleteTenantParams) *models.Error {
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return prepareError(err)
}
// get Kubernetes Client
clientset, err := cluster.K8sClient(session.SessionToken)
clientset, err := cluster.K8sClient(session.STSSessionToken)
if err != nil {
return prepareError(err)
}
@@ -336,7 +336,7 @@ func getTenantInfoResponse(session *models.Principal, params admin_api.TenantInf
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}
@@ -409,7 +409,7 @@ func listTenants(ctx context.Context, operatorClient OperatorClientI, namespace
func getListAllTenantsResponse(session *models.Principal, params admin_api.ListAllTenantsParams) (*models.ListTenantsResponse, *models.Error) {
ctx := context.Background()
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}
@@ -426,7 +426,7 @@ func getListAllTenantsResponse(session *models.Principal, params admin_api.ListA
// getListTenantsResponse list tenants by namespace
func getListTenantsResponse(session *models.Principal, params admin_api.ListTenantsParams) (*models.ListTenantsResponse, *models.Error) {
ctx := context.Background()
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}
@@ -454,7 +454,7 @@ func getTenantCreatedResponse(session *models.Principal, params admin_api.Create
}
}
// get Kubernetes Client
clientSet, err := cluster.K8sClient(session.SessionToken)
clientSet, err := cluster.K8sClient(session.STSSessionToken)
k8sClient := k8sClient{
client: clientSet,
}
@@ -779,7 +779,7 @@ func getTenantCreatedResponse(session *models.Principal, params admin_api.Create
minInst.Spec.Console.Image = tenantReq.ConsoleImage
}
opClient, err := cluster.OperatorClient(session.SessionToken)
opClient, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}
@@ -792,7 +792,7 @@ func getTenantCreatedResponse(session *models.Principal, params admin_api.Create
// Integratrions
if os.Getenv("GKE_INTEGRATION") != "" {
err := gkeIntegration(clientSet, tenantName, ns, session.SessionToken)
err := gkeIntegration(clientSet, tenantName, ns, session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}
@@ -967,12 +967,12 @@ func removeAnnotations(annotationsOne, annotationsTwo map[string]string) map[str
func getUpdateTenantResponse(session *models.Principal, params admin_api.UpdateTenantParams) *models.Error {
ctx := context.Background()
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return prepareError(err)
}
// get Kubernetes Client
clientSet, err := cluster.K8sClient(session.SessionToken)
clientSet, err := cluster.K8sClient(session.STSSessionToken)
if err != nil {
return prepareError(err)
}
@@ -1017,7 +1017,7 @@ func addTenantPool(ctx context.Context, operatorClient OperatorClientI, params a
func getTenantAddPoolResponse(session *models.Principal, params admin_api.TenantAddPoolParams) *models.Error {
ctx := context.Background()
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return prepareError(err)
}
@@ -1036,11 +1036,11 @@ func getTenantUsageResponse(session *models.Principal, params admin_api.GetTenan
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err, errorUnableToGetTenantUsage)
}
clientSet, err := cluster.K8sClient(session.SessionToken)
clientSet, err := cluster.K8sClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err, errorUnableToGetTenantUsage)
}
@@ -1496,7 +1496,7 @@ func parseNodeSelectorTerm(term *corev1.NodeSelectorTerm) *models.NodeSelectorTe
func getTenantUpdatePoolResponse(session *models.Principal, params admin_api.TenantUpdatePoolsParams) (*models.Tenant, *models.Error) {
ctx := context.Background()
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}

View File

@@ -85,14 +85,14 @@ func tenantUpdateCertificates(ctx context.Context, operatorClient OperatorClient
func getTenantUpdateCertificatesResponse(session *models.Principal, params admin_api.TenantUpdateCertificateParams) *models.Error {
ctx := context.Background()
// get Kubernetes Client
clientSet, err := cluster.K8sClient(session.SessionToken)
clientSet, err := cluster.K8sClient(session.STSSessionToken)
if err != nil {
return prepareError(err, errorUnableToUpdateTenantCertificates)
}
k8sClient := k8sClient{
client: clientSet,
}
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return prepareError(err, errorUnableToUpdateTenantCertificates)
}
@@ -163,14 +163,14 @@ func tenantUpdateEncryption(ctx context.Context, operatorClient OperatorClientI,
func getTenantUpdateEncryptionResponse(session *models.Principal, params admin_api.TenantUpdateEncryptionParams) *models.Error {
ctx := context.Background()
// get Kubernetes Client
clientSet, err := cluster.K8sClient(session.SessionToken)
clientSet, err := cluster.K8sClient(session.STSSessionToken)
if err != nil {
return prepareError(err, errorUpdatingEncryptionConfig)
}
k8sClient := k8sClient{
client: clientSet,
}
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return prepareError(err, errorUpdatingEncryptionConfig)
}

View File

@@ -47,3 +47,7 @@ func (ac adminClientMock) removeRemoteBucket(ctx context.Context, bucket, arn st
func (ac adminClientMock) addRemoteBucket(ctx context.Context, bucket string, target *madmin.BucketTarget) (string, error) {
return "", nil
}
func (ac adminClientMock) changePassword(ctx context.Context, accessKey, secretKey string) error {
return minioChangePasswordMock(ctx, accessKey, secretKey)
}

View File

@@ -105,6 +105,8 @@ type MinioAdmin interface {
getRemoteBucket(ctx context.Context, bucket, arnType string) (targets *madmin.BucketTarget, err error)
removeRemoteBucket(ctx context.Context, bucket, arn string) error
addRemoteBucket(ctx context.Context, bucket string, target *madmin.BucketTarget) (string, error)
// Account password management
changePassword(ctx context.Context, accessKey, secretKey string) error
}
// Interface implementation
@@ -115,14 +117,18 @@ type adminClient struct {
client *madmin.AdminClient
}
func (ac adminClient) changePassword(ctx context.Context, accessKey, secretKey string) error {
return ac.client.SetUser(ctx, accessKey, secretKey, madmin.AccountEnabled)
}
// implements madmin.ListUsers()
func (ac adminClient) listUsers(ctx context.Context) (map[string]madmin.UserInfo, error) {
return ac.client.ListUsers(ctx)
}
// implements madmin.AddUser()
func (ac adminClient) addUser(ctx context.Context, acessKey, secretKey string) error {
return ac.client.AddUser(ctx, acessKey, secretKey)
func (ac adminClient) addUser(ctx context.Context, accessKey, secretKey string) error {
return ac.client.AddUser(ctx, accessKey, secretKey)
}
// implements madmin.RemoveUser()
@@ -301,7 +307,7 @@ func newAdminFromClaims(claims *models.Principal) (*madmin.AdminClient, error) {
endpoint := getMinIOEndpoint()
adminClient, err := madmin.NewWithOptions(endpoint, &madmin.Options{
Creds: credentials.NewStaticV4(claims.AccessKeyID, claims.SecretAccessKey, claims.SessionToken),
Creds: credentials.NewStaticV4(claims.STSAccessKeyID, claims.STSSecretAccessKey, claims.STSSessionToken),
Secure: tlsEnabled,
})
if err != nil {

View File

@@ -229,17 +229,35 @@ func (c mcClient) shareDownload(ctx context.Context, versionID string, expires t
return c.client.ShareDownload(ctx, versionID, expires)
}
// ConsoleCredentials interface with all functions to be implemented
// ConsoleCredentialsI interface with all functions to be implemented
// by mock when testing, it should include all needed consoleCredentials.Login api calls
// that are used within this project.
type ConsoleCredentials interface {
type ConsoleCredentialsI interface {
Get() (credentials.Value, error)
Expire()
GetAccountAccessKey() string
GetAccountSecretKey() string
GetActions() []string
}
// Interface implementation
type consoleCredentials struct {
consoleCredentials *credentials.Credentials
accountAccessKey string
accountSecretKey string
actions []string
}
func (c consoleCredentials) GetActions() []string {
return c.actions
}
func (c consoleCredentials) GetAccountAccessKey() string {
return c.accountAccessKey
}
func (c consoleCredentials) GetAccountSecretKey() string {
return c.accountSecretKey
}
// implements *Login.Get()
@@ -269,6 +287,7 @@ func (s consoleSTSAssumeRole) IsExpired() bool {
var (
MinioEndpoint = getMinIOServer()
MinioRegion = getMinIORegion()
)
func newConsoleCredentials(accessKey, secretKey, location string) (*credentials.Credentials, error) {
@@ -321,7 +340,7 @@ func newConsoleCredentials(accessKey, secretKey, location string) (*credentials.
// getConsoleCredentialsFromSession returns the *consoleCredentials.Login associated to the
// provided session token, this is useful for running the Expire() or IsExpired() operations
func getConsoleCredentialsFromSession(claims *models.Principal) *credentials.Credentials {
return credentials.NewStaticV4(claims.AccessKeyID, claims.SecretAccessKey, claims.SessionToken)
return credentials.NewStaticV4(claims.STSAccessKeyID, claims.STSSecretAccessKey, claims.STSSessionToken)
}
// newMinioClient creates a new MinIO client based on the consoleCredentials extracted
@@ -355,7 +374,7 @@ func newS3BucketClient(claims *models.Principal, bucketName string, prefix strin
return nil, fmt.Errorf("the provided credentials are invalid")
}
s3Config := newS3Config(endpoint, claims.AccessKeyID, claims.SecretAccessKey, claims.SessionToken, false)
s3Config := newS3Config(endpoint, claims.STSAccessKeyID, claims.STSSecretAccessKey, claims.STSSessionToken, false)
client, pErr := mc.S3New(s3Config)
if pErr != nil {
return nil, pErr.Cause
@@ -378,7 +397,7 @@ func newTenantS3BucketClient(claims *models.Principal, tenantEndpoint, bucketNam
return nil, fmt.Errorf("the provided credentials are invalid")
}
s3Config := newS3Config(tenantEndpoint, claims.AccessKeyID, claims.SecretAccessKey, claims.SessionToken, false)
s3Config := newS3Config(tenantEndpoint, claims.STSAccessKeyID, claims.STSSecretAccessKey, claims.STSSessionToken, false)
client, pErr := mc.S3New(s3Config)
if pErr != nil {
return nil, pErr.Cause

View File

@@ -56,6 +56,10 @@ func getMinIOServer() string {
return strings.TrimSpace(env.Get(ConsoleMinIOServer, "http://localhost:9000"))
}
func getMinIORegion() string {
return strings.TrimSpace(env.Get(ConsoleMinIORegion, ""))
}
func getMinIOEndpoint() string {
server := getMinIOServer()
if strings.Contains(server, "://") {

View File

@@ -80,10 +80,12 @@ func configureAPI(api *operations.ConsoleAPI) http.Handler {
return nil, errors.New(401, "incorrect api key auth")
}
return &models.Principal{
AccessKeyID: claims.AccessKeyID,
Actions: claims.Actions,
SecretAccessKey: claims.SecretAccessKey,
SessionToken: claims.SessionToken,
STSAccessKeyID: claims.STSAccessKeyID,
Actions: claims.Actions,
STSSecretAccessKey: claims.STSSecretAccessKey,
STSSessionToken: claims.STSSessionToken,
AccountAccessKey: claims.AccountAccessKey,
AccountSecretKey: claims.AccountSecretKey,
}, nil
}
@@ -135,6 +137,8 @@ func configureAPI(api *operations.ConsoleAPI) http.Handler {
registerBucketQuotaHandlers(api)
// List buckets
registerOperatorBucketsHandlers(api)
// Register Account handlers
registerAccountHandlers(api)
api.PreServerShutdown = func() {}

View File

@@ -22,6 +22,7 @@ const (
ConsoleAccessKey = "CONSOLE_ACCESS_KEY"
ConsoleSecretKey = "CONSOLE_SECRET_KEY"
ConsoleMinIOServer = "CONSOLE_MINIO_SERVER"
ConsoleMinIORegion = "CONSOLE_MINIO_REGION"
ConsoleProductionMode = "CONSOLE_PRODUCTION_MODE"
ConsoleHostname = "CONSOLE_HOSTNAME"
ConsolePort = "CONSOLE_PORT"

View File

@@ -52,6 +52,39 @@ func init() {
},
"basePath": "/api/v1",
"paths": {
"/account/change-password": {
"post": {
"tags": [
"UserAPI"
],
"summary": "Change password of currently logged in user.",
"operationId": "AccountChangePassword",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/accountChangePasswordRequest"
}
}
],
"responses": {
"201": {
"description": "A successful login.",
"schema": {
"$ref": "#/definitions/loginResponse"
}
},
"default": {
"description": "Generic error response.",
"schema": {
"$ref": "#/definitions/error"
}
}
}
}
},
"/admin/arns": {
"get": {
"tags": [
@@ -2909,6 +2942,21 @@ func init() {
}
},
"definitions": {
"accountChangePasswordRequest": {
"type": "object",
"required": [
"current_secret_key",
"new_secret_key"
],
"properties": {
"current_secret_key": {
"type": "string"
},
"new_secret_key": {
"type": "string"
}
}
},
"addBucketReplication": {
"type": "object",
"properties": {
@@ -4490,7 +4538,19 @@ func init() {
"principal": {
"type": "object",
"properties": {
"accessKeyID": {
"STSAccessKeyID": {
"type": "string"
},
"STSSecretAccessKey": {
"type": "string"
},
"STSSessionToken": {
"type": "string"
},
"accountAccessKey": {
"type": "string"
},
"accountSecretKey": {
"type": "string"
},
"actions": {
@@ -4498,12 +4558,6 @@ func init() {
"items": {
"type": "string"
}
},
"secretAccessKey": {
"type": "string"
},
"sessionToken": {
"type": "string"
}
}
},
@@ -5112,6 +5166,39 @@ func init() {
},
"basePath": "/api/v1",
"paths": {
"/account/change-password": {
"post": {
"tags": [
"UserAPI"
],
"summary": "Change password of currently logged in user.",
"operationId": "AccountChangePassword",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/accountChangePasswordRequest"
}
}
],
"responses": {
"201": {
"description": "A successful login.",
"schema": {
"$ref": "#/definitions/loginResponse"
}
},
"default": {
"description": "Generic error response.",
"schema": {
"$ref": "#/definitions/error"
}
}
}
}
},
"/admin/arns": {
"get": {
"tags": [
@@ -8492,6 +8579,21 @@ func init() {
}
}
},
"accountChangePasswordRequest": {
"type": "object",
"required": [
"current_secret_key",
"new_secret_key"
],
"properties": {
"current_secret_key": {
"type": "string"
},
"new_secret_key": {
"type": "string"
}
}
},
"addBucketReplication": {
"type": "object",
"properties": {
@@ -9938,7 +10040,19 @@ func init() {
"principal": {
"type": "object",
"properties": {
"accessKeyID": {
"STSAccessKeyID": {
"type": "string"
},
"STSSecretAccessKey": {
"type": "string"
},
"STSSessionToken": {
"type": "string"
},
"accountAccessKey": {
"type": "string"
},
"accountSecretKey": {
"type": "string"
},
"actions": {
@@ -9946,12 +10060,6 @@ func init() {
"items": {
"type": "string"
}
},
"secretAccessKey": {
"type": "string"
},
"sessionToken": {
"type": "string"
}
}
},

View File

@@ -18,7 +18,6 @@ var (
errorGenericUnauthorized = errors.New("unauthorized")
errorGenericForbidden = errors.New("forbidden")
errorGenericNotFound = errors.New("not found")
errConnectingToMinio = errors.New("unable to connect to MinIO instance")
// Explicit error messages
errorInvalidErasureCodingValue = errors.New("invalid Erasure Coding Value")
errorUnableToGetTenantUsage = errors.New("unable to get tenant usage")
@@ -32,6 +31,7 @@ var (
errPolicyBodyNotInRequest = errors.New("error policy body not in request")
errInvalidEncryptionAlgorithm = errors.New("error invalid encryption algorithm")
errSSENotConfigured = errors.New("error server side encryption configuration was not found")
errChangePassword = errors.New("unable to update password, please check your current password")
)
// prepareError receives an error object and parse it against k8sErrors, returns the right error code paired with a generic error message
@@ -90,6 +90,11 @@ func prepareError(err ...error) *models.Error {
errorCode = 401
errorMessage = errorGenericInvalidSession.Error()
}
// account change password
if errors.Is(err[0], errChangePassword) {
errorCode = 403
errorMessage = errChangePassword.Error()
}
if madmin.ToErrorResponse(err[0]).Code == "InvalidAccessKeyId" {
errorCode = 401
errorMessage = errorGenericInvalidSession.Error()

View File

@@ -65,6 +65,9 @@ func NewConsoleAPI(spec *loads.Document) *ConsoleAPI {
BinProducer: runtime.ByteStreamProducer(),
JSONProducer: runtime.JSONProducer(),
UserAPIAccountChangePasswordHandler: user_api.AccountChangePasswordHandlerFunc(func(params user_api.AccountChangePasswordParams, principal *models.Principal) middleware.Responder {
return middleware.NotImplemented("operation user_api.AccountChangePassword has not yet been implemented")
}),
UserAPIAddBucketReplicationHandler: user_api.AddBucketReplicationHandlerFunc(func(params user_api.AddBucketReplicationParams, principal *models.Principal) middleware.Responder {
return middleware.NotImplemented("operation user_api.AddBucketReplication has not yet been implemented")
}),
@@ -366,6 +369,8 @@ type ConsoleAPI struct {
// APIAuthorizer provides access control (ACL/RBAC/ABAC) by providing access to the request and authenticated principal
APIAuthorizer runtime.Authorizer
// UserAPIAccountChangePasswordHandler sets the operation handler for the account change password operation
UserAPIAccountChangePasswordHandler user_api.AccountChangePasswordHandler
// UserAPIAddBucketReplicationHandler sets the operation handler for the add bucket replication operation
UserAPIAddBucketReplicationHandler user_api.AddBucketReplicationHandler
// AdminAPIAddGroupHandler sets the operation handler for the add group operation
@@ -608,6 +613,9 @@ func (o *ConsoleAPI) Validate() error {
unregistered = append(unregistered, "KeyAuth")
}
if o.UserAPIAccountChangePasswordHandler == nil {
unregistered = append(unregistered, "user_api.AccountChangePasswordHandler")
}
if o.UserAPIAddBucketReplicationHandler == nil {
unregistered = append(unregistered, "user_api.AddBucketReplicationHandler")
}
@@ -959,6 +967,10 @@ func (o *ConsoleAPI) initHandlerCache() {
o.handlers = make(map[string]map[string]http.Handler)
}
if o.handlers["POST"] == nil {
o.handlers["POST"] = make(map[string]http.Handler)
}
o.handlers["POST"]["/account/change-password"] = user_api.NewAccountChangePassword(o.context, o.UserAPIAccountChangePasswordHandler)
if o.handlers["POST"] == nil {
o.handlers["POST"] = make(map[string]http.Handler)
}

View File

@@ -0,0 +1,90 @@
// Code generated by go-swagger; DO NOT EDIT.
// 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/>.
//
package user_api
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the generate command
import (
"net/http"
"github.com/go-openapi/runtime/middleware"
"github.com/minio/console/models"
)
// AccountChangePasswordHandlerFunc turns a function with the right signature into a account change password handler
type AccountChangePasswordHandlerFunc func(AccountChangePasswordParams, *models.Principal) middleware.Responder
// Handle executing the request and returning a response
func (fn AccountChangePasswordHandlerFunc) Handle(params AccountChangePasswordParams, principal *models.Principal) middleware.Responder {
return fn(params, principal)
}
// AccountChangePasswordHandler interface for that can handle valid account change password params
type AccountChangePasswordHandler interface {
Handle(AccountChangePasswordParams, *models.Principal) middleware.Responder
}
// NewAccountChangePassword creates a new http.Handler for the account change password operation
func NewAccountChangePassword(ctx *middleware.Context, handler AccountChangePasswordHandler) *AccountChangePassword {
return &AccountChangePassword{Context: ctx, Handler: handler}
}
/*AccountChangePassword swagger:route POST /account/change-password UserAPI accountChangePassword
Change password of currently logged in user.
*/
type AccountChangePassword struct {
Context *middleware.Context
Handler AccountChangePasswordHandler
}
func (o *AccountChangePassword) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
route, rCtx, _ := o.Context.RouteInfo(r)
if rCtx != nil {
r = rCtx
}
var Params = NewAccountChangePasswordParams()
uprinc, aCtx, err := o.Context.Authorize(r, route)
if err != nil {
o.Context.Respond(rw, r, route.Produces, route, err)
return
}
if aCtx != nil {
r = aCtx
}
var principal *models.Principal
if uprinc != nil {
principal = uprinc.(*models.Principal) // this is really a models.Principal, I promise
}
if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params
o.Context.Respond(rw, r, route.Produces, route, err)
return
}
res := o.Handler.Handle(Params, principal) // actually handle the request
o.Context.Respond(rw, r, route.Produces, route, res)
}

View File

@@ -0,0 +1,94 @@
// Code generated by go-swagger; DO NOT EDIT.
// 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/>.
//
package user_api
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the swagger generate command
import (
"io"
"net/http"
"github.com/go-openapi/errors"
"github.com/go-openapi/runtime"
"github.com/go-openapi/runtime/middleware"
"github.com/minio/console/models"
)
// NewAccountChangePasswordParams creates a new AccountChangePasswordParams object
// no default values defined in spec.
func NewAccountChangePasswordParams() AccountChangePasswordParams {
return AccountChangePasswordParams{}
}
// AccountChangePasswordParams contains all the bound params for the account change password operation
// typically these are obtained from a http.Request
//
// swagger:parameters AccountChangePassword
type AccountChangePasswordParams struct {
// HTTP Request Object
HTTPRequest *http.Request `json:"-"`
/*
Required: true
In: body
*/
Body *models.AccountChangePasswordRequest
}
// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface
// for simple values it will use straight method calls.
//
// To ensure default values, the struct must have been initialized with NewAccountChangePasswordParams() beforehand.
func (o *AccountChangePasswordParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error {
var res []error
o.HTTPRequest = r
if runtime.HasBody(r) {
defer r.Body.Close()
var body models.AccountChangePasswordRequest
if err := route.Consumer.Consume(r.Body, &body); err != nil {
if err == io.EOF {
res = append(res, errors.Required("body", "body", ""))
} else {
res = append(res, errors.NewParseError("body", "body", "", err))
}
} else {
// validate body object
if err := body.Validate(route.Formats); err != nil {
res = append(res, err)
}
if len(res) == 0 {
o.Body = &body
}
}
} else {
res = append(res, errors.Required("body", "body", ""))
}
if len(res) > 0 {
return errors.CompositeValidationError(res...)
}
return nil
}

View File

@@ -0,0 +1,133 @@
// Code generated by go-swagger; DO NOT EDIT.
// 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/>.
//
package user_api
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the swagger generate command
import (
"net/http"
"github.com/go-openapi/runtime"
"github.com/minio/console/models"
)
// AccountChangePasswordCreatedCode is the HTTP code returned for type AccountChangePasswordCreated
const AccountChangePasswordCreatedCode int = 201
/*AccountChangePasswordCreated A successful login.
swagger:response accountChangePasswordCreated
*/
type AccountChangePasswordCreated struct {
/*
In: Body
*/
Payload *models.LoginResponse `json:"body,omitempty"`
}
// NewAccountChangePasswordCreated creates AccountChangePasswordCreated with default headers values
func NewAccountChangePasswordCreated() *AccountChangePasswordCreated {
return &AccountChangePasswordCreated{}
}
// WithPayload adds the payload to the account change password created response
func (o *AccountChangePasswordCreated) WithPayload(payload *models.LoginResponse) *AccountChangePasswordCreated {
o.Payload = payload
return o
}
// SetPayload sets the payload to the account change password created response
func (o *AccountChangePasswordCreated) SetPayload(payload *models.LoginResponse) {
o.Payload = payload
}
// WriteResponse to the client
func (o *AccountChangePasswordCreated) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
rw.WriteHeader(201)
if o.Payload != nil {
payload := o.Payload
if err := producer.Produce(rw, payload); err != nil {
panic(err) // let the recovery middleware deal with this
}
}
}
/*AccountChangePasswordDefault Generic error response.
swagger:response accountChangePasswordDefault
*/
type AccountChangePasswordDefault struct {
_statusCode int
/*
In: Body
*/
Payload *models.Error `json:"body,omitempty"`
}
// NewAccountChangePasswordDefault creates AccountChangePasswordDefault with default headers values
func NewAccountChangePasswordDefault(code int) *AccountChangePasswordDefault {
if code <= 0 {
code = 500
}
return &AccountChangePasswordDefault{
_statusCode: code,
}
}
// WithStatusCode adds the status to the account change password default response
func (o *AccountChangePasswordDefault) WithStatusCode(code int) *AccountChangePasswordDefault {
o._statusCode = code
return o
}
// SetStatusCode sets the status to the account change password default response
func (o *AccountChangePasswordDefault) SetStatusCode(code int) {
o._statusCode = code
}
// WithPayload adds the payload to the account change password default response
func (o *AccountChangePasswordDefault) WithPayload(payload *models.Error) *AccountChangePasswordDefault {
o.Payload = payload
return o
}
// SetPayload sets the payload to the account change password default response
func (o *AccountChangePasswordDefault) SetPayload(payload *models.Error) {
o.Payload = payload
}
// WriteResponse to the client
func (o *AccountChangePasswordDefault) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) {
rw.WriteHeader(o._statusCode)
if o.Payload != nil {
payload := o.Payload
if err := producer.Produce(rw, payload); err != nil {
panic(err) // let the recovery middleware deal with this
}
}
}

View File

@@ -0,0 +1,104 @@
// Code generated by go-swagger; DO NOT EDIT.
// 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/>.
//
package user_api
// This file was generated by the swagger tool.
// Editing this file might prove futile when you re-run the generate command
import (
"errors"
"net/url"
golangswaggerpaths "path"
)
// AccountChangePasswordURL generates an URL for the account change password operation
type AccountChangePasswordURL struct {
_basePath string
}
// WithBasePath sets the base path for this url builder, only required when it's different from the
// base path specified in the swagger spec.
// When the value of the base path is an empty string
func (o *AccountChangePasswordURL) WithBasePath(bp string) *AccountChangePasswordURL {
o.SetBasePath(bp)
return o
}
// SetBasePath sets the base path for this url builder, only required when it's different from the
// base path specified in the swagger spec.
// When the value of the base path is an empty string
func (o *AccountChangePasswordURL) SetBasePath(bp string) {
o._basePath = bp
}
// Build a url path and query string
func (o *AccountChangePasswordURL) Build() (*url.URL, error) {
var _result url.URL
var _path = "/account/change-password"
_basePath := o._basePath
if _basePath == "" {
_basePath = "/api/v1"
}
_result.Path = golangswaggerpaths.Join(_basePath, _path)
return &_result, nil
}
// Must is a helper function to panic when the url builder returns an error
func (o *AccountChangePasswordURL) Must(u *url.URL, err error) *url.URL {
if err != nil {
panic(err)
}
if u == nil {
panic("url can't be nil")
}
return u
}
// String returns the string representation of the path with query string
func (o *AccountChangePasswordURL) String() string {
return o.Must(o.Build()).String()
}
// BuildFull builds a full url with scheme, host, path and query string
func (o *AccountChangePasswordURL) BuildFull(scheme, host string) (*url.URL, error) {
if scheme == "" {
return nil, errors.New("scheme is required for a full url on AccountChangePasswordURL")
}
if host == "" {
return nil, errors.New("host is required for a full url on AccountChangePasswordURL")
}
base, err := o.Build()
if err != nil {
return nil, err
}
base.Scheme = scheme
base.Host = host
return base, nil
}
// StringFull returns the string representation of a complete url
func (o *AccountChangePasswordURL) StringFull(scheme, host string) string {
return o.Must(o.BuildFull(scheme, host)).String()
}

View File

@@ -43,11 +43,11 @@ func getOperatorListBucketsResponse(session *models.Principal, namespace, tenant
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}
clientSet, err := cluster.K8sClient(session.SessionToken)
clientSet, err := cluster.K8sClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}

View File

@@ -68,7 +68,7 @@ func getResourceQuota(ctx context.Context, client K8sClientI, namespace, resourc
func getResourceQuotaResponse(session *models.Principal, params admin_api.GetResourceQuotaParams) (*models.ResourceQuota, *models.Error) {
ctx := context.Background()
client, err := cluster.K8sClient(session.SessionToken)
client, err := cluster.K8sClient(session.STSSessionToken)
if err != nil {
return nil, prepareError(err)
}

97
restapi/user_account.go Normal file
View File

@@ -0,0 +1,97 @@
// 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/>.
package restapi
import (
"context"
"net/http"
"time"
"github.com/go-openapi/runtime"
"github.com/go-openapi/runtime/middleware"
"github.com/minio/console/models"
"github.com/minio/console/restapi/operations"
"github.com/minio/console/restapi/operations/user_api"
)
func registerAccountHandlers(api *operations.ConsoleAPI) {
// change user password
api.UserAPIAccountChangePasswordHandler = user_api.AccountChangePasswordHandlerFunc(func(params user_api.AccountChangePasswordParams, session *models.Principal) middleware.Responder {
changePasswordResponse, err := getChangePasswordResponse(session, params)
if err != nil {
return user_api.NewAccountChangePasswordDefault(int(err.Code)).WithPayload(err)
}
// Custom response writer to update the session cookies
return middleware.ResponderFunc(func(w http.ResponseWriter, p runtime.Producer) {
cookie := NewSessionCookieForConsole(changePasswordResponse.SessionID)
http.SetCookie(w, &cookie)
user_api.NewLoginCreated().WithPayload(changePasswordResponse).WriteResponse(w, p)
})
})
}
// changePassword validate current current user password and if it's correct set the new password
func changePassword(ctx context.Context, client MinioAdmin, session *models.Principal, currentSecretKey, newSecretKey string) error {
if session.AccountSecretKey != currentSecretKey {
return errChangePassword
}
if err := client.changePassword(ctx, session.AccountAccessKey, newSecretKey); err != nil {
return err
}
return nil
}
// getChangePasswordResponse will validate user knows what is the current password (avoid account hijacking), update user account password
// and authenticate the user generating a new session token/cookie
func getChangePasswordResponse(session *models.Principal, params user_api.AccountChangePasswordParams) (*models.LoginResponse, *models.Error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// changePassword operations requires an AdminClient initialized with parent account credentials not
// STS credentials
parentAccountClient, err := newMAdminClient(&models.Principal{
STSAccessKeyID: session.AccountAccessKey,
STSSecretAccessKey: session.AccountSecretKey,
})
if err != nil {
return nil, prepareError(err)
}
// parentAccountClient will contain access and secret key credentials for the user
userClient := adminClient{client: parentAccountClient}
accessKey := session.AccountAccessKey
currentSecretKey := *params.Body.CurrentSecretKey
newSecretKey := *params.Body.NewSecretKey
// currentSecretKey will compare currentSecretKey against the stored secret key inside the encrypted session
if err := changePassword(ctx, userClient, session, currentSecretKey, newSecretKey); err != nil {
return nil, prepareError(errChangePassword, nil, err)
}
// user credentials are updated at this point, we need to generate a new admin client and authenticate using
// the new credentials
credentials, err := getConsoleCredentials(ctx, accessKey, newSecretKey)
if err != nil {
return nil, prepareError(errInvalidCredentials, nil, err)
}
// authenticate user and generate new session token
sessionID, err := login(credentials)
if err != nil {
return nil, prepareError(errInvalidCredentials, nil, err)
}
// serialize output
loginResponse := &models.LoginResponse{
SessionID: *sessionID,
}
return loginResponse, nil
}

View File

@@ -0,0 +1,111 @@
// 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/>.
package restapi
import (
"context"
"errors"
"testing"
"github.com/minio/console/models"
)
var minioChangePasswordMock func(ctx context.Context, accessKey, secretKey string) error
func Test_changePassword(t *testing.T) {
client := adminClientMock{}
type args struct {
ctx context.Context
client adminClientMock
session *models.Principal
currentSecretKey string
newSecretKey string
}
tests := []struct {
name string
args args
wantErr bool
mock func()
}{
{
name: "password changed successfully",
args: args{
client: client,
ctx: context.Background(),
session: &models.Principal{
AccountAccessKey: "TESTTEST",
AccountSecretKey: "TESTTEST",
},
currentSecretKey: "TESTTEST",
newSecretKey: "TESTTEST2",
},
mock: func() {
minioChangePasswordMock = func(ctx context.Context, accessKey, secretKey string) error {
return nil
}
},
},
{
name: "error when changing password",
args: args{
client: client,
ctx: context.Background(),
session: &models.Principal{
AccountAccessKey: "TESTTEST",
AccountSecretKey: "TESTTEST",
},
currentSecretKey: "TESTTEST",
newSecretKey: "TESTTEST2",
},
mock: func() {
minioChangePasswordMock = func(ctx context.Context, accessKey, secretKey string) error {
return errors.New("there was an error, please try again")
}
},
wantErr: true,
},
{
name: "error because current password doesn't match",
args: args{
client: client,
ctx: context.Background(),
session: &models.Principal{
AccountAccessKey: "TESTTEST",
AccountSecretKey: "TESTTEST123",
},
currentSecretKey: "TESTTEST",
newSecretKey: "TESTTEST2",
},
mock: func() {
minioChangePasswordMock = func(ctx context.Context, accessKey, secretKey string) error {
return errors.New("there was an error, please try again")
}
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.mock != nil {
tt.mock()
}
if err := changePassword(tt.args.ctx, tt.args.client, tt.args.session, tt.args.currentSecretKey, tt.args.newSecretKey); (err != nil) != tt.wantErr {
t.Errorf("changePassword() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -83,14 +83,14 @@ func registerLoginHandlers(api *operations.ConsoleAPI) {
// login performs a check of consoleCredentials against MinIO, generates some claims and returns the jwt
// for subsequent authentication
func login(credentials ConsoleCredentials, actions []string) (*string, error) {
func login(credentials ConsoleCredentialsI) (*string, error) {
// try to obtain consoleCredentials,
tokens, err := credentials.Get()
if err != nil {
return nil, err
}
// if we made it here, the consoleCredentials work, generate a jwt with claims
token, err := auth.NewEncryptedTokenForClient(&tokens, actions)
token, err := auth.NewEncryptedTokenForClient(&tokens, credentials.GetAccountAccessKey(), credentials.GetAccountSecretKey(), credentials.GetActions())
if err != nil {
log.Println("error authenticating user", err)
return nil, errInvalidCredentials
@@ -112,33 +112,22 @@ func getConfiguredRegionForLogin(ctx context.Context, client MinioAdmin) (string
return location, nil
}
// getLoginResponse performs login() and serializes it to the handler's output
func getLoginResponse(lr *models.LoginRequest) (*models.LoginResponse, *models.Error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
mAdmin, err := newSuperMAdminClient()
func getConsoleCredentials(ctx context.Context, accessKey, secretKey string) (*consoleCredentials, error) {
mAdminClient, err := newMAdminClient(&models.Principal{
STSAccessKeyID: accessKey,
STSSecretAccessKey: secretKey,
})
if err != nil {
return nil, prepareError(err, errorGeneric)
return nil, err
}
adminClient := adminClient{client: mAdmin}
// obtain the configured MinIO region
// need it for user authentication
location, err := getConfiguredRegionForLogin(ctx, adminClient)
if err != nil {
return nil, prepareError(err, errConnectingToMinio)
}
creds, err := newConsoleCredentials(*lr.AccessKey, *lr.SecretKey, location)
if err != nil {
return nil, prepareError(err, errInvalidCredentials)
}
credentials := consoleCredentials{consoleCredentials: creds}
userAdminClient := adminClient{client: mAdminClient}
// obtain the current policy assigned to this user
// necessary for generating the list of allowed endpoints
userInfo, err := adminClient.getUserInfo(ctx, *lr.AccessKey)
userInfo, err := userAdminClient.getUserInfo(ctx, accessKey)
if err != nil {
return nil, prepareError(err, errorGeneric)
return nil, err
}
policy, _ := adminClient.getPolicy(ctx, userInfo.PolicyName)
policy, _ := userAdminClient.getPolicy(ctx, userInfo.PolicyName)
// by default every user starts with an empty array of available actions
// therefore we would have access only to pages that doesn't require any privilege
// ie: service-account page
@@ -147,7 +136,29 @@ func getLoginResponse(lr *models.LoginRequest) (*models.LoginResponse, *models.E
if policy != nil {
actions = acl.GetActionsStringFromPolicy(policy)
}
sessionID, err := login(credentials, actions)
creds, err := newConsoleCredentials(accessKey, secretKey, MinioRegion)
if err != nil {
return nil, err
}
// consoleCredentials will be sts credentials, account credentials will be need it in the scenario the user wish
return &consoleCredentials{
consoleCredentials: creds,
accountAccessKey: accessKey,
accountSecretKey: secretKey,
actions: actions,
}, nil
}
// getLoginResponse performs login() and serializes it to the handler's output
func getLoginResponse(lr *models.LoginRequest) (*models.LoginResponse, *models.Error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// prepare console credentials
credentials, err := getConsoleCredentials(ctx, *lr.AccessKey, *lr.SecretKey)
if err != nil {
return nil, prepareError(errInvalidCredentials, nil, err)
}
sessionID, err := login(credentials)
if err != nil {
return nil, prepareError(errInvalidCredentials, nil, err)
}
@@ -250,8 +261,8 @@ func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.Logi
if err != nil {
return nil, prepareError(err)
}
credentials := consoleCredentials{consoleCredentials: creds}
token, err := login(credentials, actions)
credentials := consoleCredentials{consoleCredentials: creds, actions: actions}
token, err := login(credentials)
if err != nil {
return nil, prepareError(errInvalidCredentials, nil, err)
}
@@ -270,9 +281,8 @@ func getLoginOperatorResponse(lmr *models.LoginOperatorRequest) (*models.LoginRe
if err != nil {
return nil, prepareError(err)
}
credentials := consoleCredentials{consoleCredentials: creds}
var actions []string
token, err := login(credentials, actions)
credentials := consoleCredentials{consoleCredentials: creds, actions: []string{}}
token, err := login(credentials)
if err != nil {
return nil, prepareError(errInvalidCredentials, nil, err)
}

View File

@@ -29,9 +29,21 @@ import (
"github.com/stretchr/testify/assert"
)
// Define a mock struct of ConsoleCredentials interface implementation
// Define a mock struct of ConsoleCredentialsI interface implementation
type consoleCredentialsMock struct{}
func (ac consoleCredentialsMock) GetActions() []string {
return []string{}
}
func (ac consoleCredentialsMock) GetAccountAccessKey() string {
return ""
}
func (ac consoleCredentialsMock) GetAccountSecretKey() string {
return ""
}
// Common mocks
var consoleCredentialsGetMock func() (credentials.Value, error)
@@ -52,7 +64,7 @@ func TestLogin(t *testing.T) {
SignerType: 0,
}, nil
}
token, err := login(consoleCredentials, []string{""})
token, err := login(consoleCredentials)
funcAssert.NotEmpty(token, "Token was returned empty")
funcAssert.Nil(err, "error creating a session")
@@ -60,7 +72,7 @@ func TestLogin(t *testing.T) {
consoleCredentialsGetMock = func() (credentials.Value, error) {
return credentials.Value{}, errors.New("")
}
_, err = login(consoleCredentials, []string{""})
_, err = login(consoleCredentials)
funcAssert.NotNil(err, "not error returned creating a session")
}

View File

@@ -42,7 +42,7 @@ func registerLogoutHandlers(api *operations.ConsoleAPI) {
}
// logout() call Expire() on the provided consoleCredentials
func logout(credentials ConsoleCredentials) {
func logout(credentials ConsoleCredentialsI) {
credentials.Expire()
}

View File

@@ -187,11 +187,11 @@ func newWebSocketTenantAdminClient(conn *websocket.Conn, session *models.Princip
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
opClientClientSet, err := cluster.OperatorClient(session.SessionToken)
opClientClientSet, err := cluster.OperatorClient(session.STSSessionToken)
if err != nil {
return nil, err
}
clientSet, err := cluster.K8sClient(session.SessionToken)
clientSet, err := cluster.K8sClient(session.STSSessionToken)
if err != nil {
return nil, err
}
@@ -238,12 +238,12 @@ func newWebSocketS3Client(conn *websocket.Conn, claims *models.Principal, namesp
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
opClientClientSet, err := cluster.OperatorClient(claims.SessionToken)
opClientClientSet, err := cluster.OperatorClient(claims.STSSessionToken)
if err != nil {
return nil, err
}
clientSet, err := cluster.K8sClient(claims.SessionToken)
clientSet, err := cluster.K8sClient(claims.STSSessionToken)
if err != nil {
return nil, err
}
@@ -268,8 +268,8 @@ func newWebSocketS3Client(conn *websocket.Conn, claims *models.Principal, namesp
}
tenantClaims := &models.Principal{
AccessKeyID: tenantCreds.accessKey,
SecretAccessKey: tenantCreds.secretKey,
STSAccessKeyID: tenantCreds.accessKey,
STSSecretAccessKey: tenantCreds.secretKey,
}
svcURL := GetTenantServiceURL(minTenant)

View File

@@ -136,6 +136,28 @@ paths:
tags:
- UserAPI
/account/change-password:
post:
summary: Change password of currently logged in user.
operationId: AccountChangePassword
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/accountChangePasswordRequest"
responses:
201:
description: A successful login.
schema:
$ref: "#/definitions/loginResponse"
default:
description: Generic error response.
schema:
$ref: "#/definitions/error"
tags:
- UserAPI
/buckets:
get:
summary: List Buckets
@@ -1953,6 +1975,18 @@ paths:
- OperatorAPI
definitions:
accountChangePasswordRequest:
type: object
required:
- current_secret_key
- new_secret_key
properties:
current_secret_key:
type: string
new_secret_key:
type: string
bucketEncryptionType:
type: string
enum:
@@ -2460,16 +2494,20 @@ definitions:
principal:
type: object
properties:
accessKeyID:
STSAccessKeyID:
type: string
secretAccessKey:
STSSecretAccessKey:
type: string
sessionToken:
STSSessionToken:
type: string
actions:
type: array
items:
type: string
accountAccessKey:
type: string
accountSecretKey:
type: string
startProfilingItem:
type: object
properties: