Connect MCS with Minio insecure TLS/Custom CAs (#102)

This PR adds support to connect MCS to minio instances running TLS with
self-signed certificates or  certificates signed by custom
Certificate Authorities

```
export MCS_MINIO_SERVER_TLS_ROOT_CAS=file1,file2,file3
```

Note: TLS Skip Verification is not supported unless there's a clear need
for it
This commit is contained in:
Lenin Alevski
2020-05-08 17:11:47 -07:00
committed by GitHub
parent cf8472b04c
commit a8c07c0969
10 changed files with 206 additions and 35 deletions

View File

@@ -68,6 +68,15 @@ export MCS_MINIO_SERVER=http://localhost:9000
./mcs server
```
## Connect MCS to a Minio using TLS and a self-signed certificate
```
...
export MCS_MINIO_SERVER_TLS_SKIP_VERIFICATION=on
export MCS_MINIO_SERVER=https://localhost:9000
./mcs server
```
You can verify that the apis work by doing the request on `localhost:9090/api/v1/...`
# Contribute to mcs Project

1
go.sum
View File

@@ -12,6 +12,7 @@ github.com/Azure/azure-storage-blob-go v0.8.0 h1:53qhf0Oxa0nOjgbDeeYPUeyiNmafAFE
github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0=
github.com/Azure/go-autorest v11.7.1+incompatible h1:M2YZIajBBVekV86x0rr1443Lc1F/Ylxb9w+5EtSyX3Q=
github.com/Azure/go-autorest v11.7.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=

View File

@@ -44,7 +44,9 @@ func NewAdminClient(url, accessKey, secretKey string) (*madmin.AdminClient, *pro
AppName: appName,
AppVersion: McsVersion,
AppComments: []string{appName, runtime.GOOS, runtime.GOARCH},
Insecure: false,
})
s3Client.SetCustomTransport(STSClient.Transport)
if err != nil {
return nil, err.Trace(url)
}
@@ -240,13 +242,15 @@ func newMAdminClient(jwt string) (*madmin.AdminClient, error) {
// newAdminFromClaims creates a minio admin from Decrypted claims using Assume role credentials
func newAdminFromClaims(claims *auth.DecryptedClaims) (*madmin.AdminClient, error) {
tlsEnabled := getMinIOEndpointIsSecure()
adminClient, err := madmin.NewWithOptions(getMinIOEndpoint(), &madmin.Options{
Creds: credentials.NewStaticV4(claims.AccessKeyID, claims.SecretAccessKey, claims.SessionToken),
Secure: getMinIOEndpointIsSecure(),
Secure: tlsEnabled,
})
if err != nil {
return nil, err
}
adminClient.SetCustomTransport(STSClient.Transport)
return adminClient, nil
}

View File

@@ -18,6 +18,7 @@ package restapi
import (
"context"
"errors"
"fmt"
mc "github.com/minio/mc/cmd"
@@ -133,13 +134,45 @@ func (c mcsCredentials) Expire() {
c.minioCredentials.Expire()
}
// mcsSTSAssumeRole it's a STSAssumeRole wrapper, in general
// there's no need to use this struct anywhere else in the project, it's only required
// for passing a custom *http.Client to *credentials.STSAssumeRole
type mcsSTSAssumeRole struct {
stsAssumeRole *credentials.STSAssumeRole
}
func (s mcsSTSAssumeRole) Retrieve() (credentials.Value, error) {
return s.stsAssumeRole.Retrieve()
}
func (s mcsSTSAssumeRole) IsExpired() bool {
return s.stsAssumeRole.IsExpired()
}
// STSClient contains http.client configuration need it by STSAssumeRole
var STSClient = PrepareSTSClient()
func newMcsCredentials(accessKey, secretKey, location string) (*credentials.Credentials, error) {
return credentials.NewSTSAssumeRole(getMinIOServer(), credentials.STSAssumeRoleOptions{
stsEndpoint := getMinIOServer()
if stsEndpoint == "" {
return nil, errors.New("STS endpoint cannot be empty")
}
if accessKey == "" || secretKey == "" {
return nil, errors.New("AssumeRole credentials access/secretkey is mandatory")
}
opts := credentials.STSAssumeRoleOptions{
AccessKey: accessKey,
SecretKey: secretKey,
Location: location,
DurationSeconds: xjwt.GetMcsSTSAndJWTDurationInSeconds(),
})
}
stsAssumeRole := &credentials.STSAssumeRole{
Client: STSClient,
STSEndpoint: stsEndpoint,
Options: opts,
}
mcsSTSWrapper := mcsSTSAssumeRole{stsAssumeRole: stsAssumeRole}
return credentials.New(mcsSTSWrapper), nil
}
// getMcsCredentialsFromJWT returns the *minioCredentials.Credentials associated to the
@@ -160,14 +193,15 @@ func newMinioClient(jwt string) (*minio.Client, error) {
if err != nil {
return nil, err
}
adminClient, err := minio.NewWithOptions(getMinIOEndpoint(), &minio.Options{
minioClient, err := minio.NewWithOptions(getMinIOEndpoint(), &minio.Options{
Creds: creds,
Secure: getMinIOEndpointIsSecure(),
})
if err != nil {
return nil, err
}
return adminClient, nil
minioClient.SetCustomTransport(STSClient.Transport)
return minioClient, nil
}
// newS3BucketClient creates a new mc S3Client to talk to the server based on a bucket

View File

@@ -48,7 +48,17 @@ func getSecretKey() string {
}
func getMinIOServer() string {
return env.Get(McsMinIOServer, "http://localhost:9000")
return strings.TrimSpace(env.Get(McsMinIOServer, "http://localhost:9000"))
}
// If MCS_MINIO_SERVER_TLS_ROOT_CAS is true mcs will load a list of certificates into the
// http.client rootCAs store, this is useful for testing or when working with self-signed certificates
func getMinioServerTLSRootCAs() []string {
caCertFileNames := strings.TrimSpace(env.Get(McsMinIOServerTLSRootCAs, ""))
if caCertFileNames == "" {
return []string{}
}
return strings.Split(caCertFileNames, ",")
}
func getMinIOEndpoint() string {
@@ -67,7 +77,7 @@ func getMinIOEndpointIsSecure() bool {
if strings.Contains(server, "://") {
parts := strings.Split(server, "://")
if len(parts) > 1 {
if parts[1] == "https" {
if parts[0] == "https" {
return true
}
}

View File

@@ -18,15 +18,16 @@ package restapi
const (
// consts for common configuration
McsVersion = `0.1.0`
McsAccessKey = "MCS_ACCESS_KEY"
McsSecretKey = "MCS_SECRET_KEY"
McsMinIOServer = "MCS_MINIO_SERVER"
McsProductionMode = "MCS_PRODUCTION_MODE"
McsHostname = "MCS_HOSTNAME"
McsPort = "MCS_PORT"
McsTLSHostname = "MCS_TLS_HOSTNAME"
McsTLSPort = "MCS_TLS_PORT"
McsVersion = `0.1.0`
McsAccessKey = "MCS_ACCESS_KEY"
McsSecretKey = "MCS_SECRET_KEY"
McsMinIOServer = "MCS_MINIO_SERVER"
McsMinIOServerTLSRootCAs = "MCS_MINIO_SERVER_TLS_ROOT_CAS"
McsProductionMode = "MCS_PRODUCTION_MODE"
McsHostname = "MCS_HOSTNAME"
McsPort = "MCS_PORT"
McsTLSHostname = "MCS_TLS_HOSTNAME"
McsTLSPort = "MCS_TLS_PORT"
// consts for Secure middleware
McsSecureAllowedHosts = "MCS_SECURE_ALLOWED_HOSTS"

95
restapi/tls.go Normal file
View File

@@ -0,0 +1,95 @@
// This file is part of MinIO Orchestrator
// 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 (
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"net"
"net/http"
"time"
)
var (
certDontExists = "File certificate doesn't exists: %s"
)
func prepareSTSClientTransport() *http.Transport {
// This takes github.com/minio/minio/pkg/madmin/transport.go as an example
//
// DefaultTransport - this default transport is similar to
// http.DefaultTransport but with additional param DisableCompression
// is set to true to avoid decompressing content with 'gzip' encoding.
DefaultTransport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 15 * time.Second,
}).DialContext,
MaxIdleConns: 1024,
MaxIdleConnsPerHost: 1024,
ResponseHeaderTimeout: 60 * time.Second,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableCompression: true,
}
// If Minio instance is running with TLS enabled and it's using a self-signed certificate
// or a certificate issued by a custom certificate authority we prepare a new custom *http.Transport
if getMinIOEndpointIsSecure() {
caCertFileNames := getMinioServerTLSRootCAs()
tlsConfig := &tls.Config{
// Can't use SSLv3 because of POODLE and BEAST
// Can't use TLSv1.0 because of POODLE and BEAST using CBC cipher
// Can't use TLSv1.1 because of RC4 cipher usage
MinVersion: tls.VersionTLS12,
}
// If root CAs are configured we save them to the http.Client RootCAs store
if len(caCertFileNames) > 0 {
certs := x509.NewCertPool()
for _, caCert := range caCertFileNames {
// Validate certificate exists
if FileExists(caCert) {
pemData, err := ioutil.ReadFile(caCert)
if err != nil {
// if there was an error reading pem file stop mcs
panic(err)
}
certs.AppendCertsFromPEM(pemData)
} else {
// if provided cert filename doesn't exists stop mcs
panic(fmt.Sprintf(certDontExists, caCert))
}
}
tlsConfig.RootCAs = certs
}
DefaultTransport.TLSClientConfig = tlsConfig
}
return DefaultTransport
}
// PrepareSTSClient returns an http.Client with custom configurations need it by *credentials.STSAssumeRole
// custom configurations include skipVerification flag, and root CA certificates
func PrepareSTSClient() *http.Client {
transport := prepareSTSClientTransport()
// Return http client with default configuration
return &http.Client{
Transport: transport,
}
}

View File

@@ -32,7 +32,8 @@ import (
)
var (
errorGeneric = errors.New("an error occurred, please try again")
errorGeneric = errors.New("an error occurred, please try again")
errInvalidCredentials = errors.New("invalid Credentials")
)
func registerLoginHandlers(api *operations.McsAPI) {
@@ -61,35 +62,35 @@ func registerLoginHandlers(api *operations.McsAPI) {
})
}
var errInvalidCredentials = errors.New("invalid minioCredentials")
// login performs a check of minioCredentials against MinIO
func login(credentials MCSCredentials) (*string, error) {
// try to obtain minioCredentials,
tokens, err := credentials.Get()
if err != nil {
log.Println("error authenticating user", err)
return nil, errInvalidCredentials
}
// if we made it here, the minioCredentials work, generate a jwt with claims
jwt, err := auth.NewJWTWithClaimsForClient(&tokens, getMinIOServer())
if err != nil {
log.Println("error authenticating user", err)
return nil, errInvalidCredentials
}
return &jwt, nil
}
func getConfiguredRegion(client MinioAdmin) string {
func getConfiguredRegionForLogin(client MinioAdmin) (string, error) {
location := ""
configuration, err := getConfig(client, "region")
if err != nil {
log.Println("error obtaining MinIO region:", err)
return location
return location, errorGeneric
}
// region is an array of 1 element
if len(configuration) > 0 {
location = configuration[0].Value
}
return location
return location, nil
}
// getLoginResponse performs login() and serializes it to the handler's output
@@ -102,16 +103,18 @@ func getLoginResponse(lr *models.LoginRequest) (*models.LoginResponse, error) {
adminClient := adminClient{client: mAdmin}
// obtain the configured MinIO region
// need it for user authentication
location := getConfiguredRegion(adminClient)
location, err := getConfiguredRegionForLogin(adminClient)
if err != nil {
return nil, err
}
creds, err := newMcsCredentials(*lr.AccessKey, *lr.SecretKey, location)
if err != nil {
log.Println("error login:", err)
return nil, err
return nil, errInvalidCredentials
}
credentials := mcsCredentials{minioCredentials: creds}
sessionID, err := login(credentials)
if err != nil {
log.Println("error login:", err)
return nil, err
}
// serialize output
@@ -131,7 +134,8 @@ func getLoginDetailsResponse() (*models.LoginDetails, error) {
// initialize new oauth2 client
oauth2Client, err := oauth2.NewOauth2ProviderClient(ctx, nil)
if err != nil {
return nil, err
log.Println("error getting new oauth2 provider client", err)
return nil, errorGeneric
}
// Validate user against IDP
identityProvider := &auth.IdentityProvider{Client: oauth2Client}
@@ -147,7 +151,8 @@ func getLoginDetailsResponse() (*models.LoginDetails, error) {
func loginOauth2Auth(ctx context.Context, provider *auth.IdentityProvider, code, state string) (*oauth2.User, error) {
userIdentity, err := provider.VerifyIdentity(ctx, code, state)
if err != nil {
return nil, err
log.Println("error validating user identity against idp:", err)
return nil, errorGeneric
}
return userIdentity, nil
}
@@ -166,8 +171,7 @@ func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.Logi
// Validate user against IDP
identity, err := loginOauth2Auth(ctx, identityProvider, *lr.Code, *lr.State)
if err != nil {
log.Println("error validating user identity against idp:", err)
return nil, errorGeneric
return nil, err
}
mAdmin, err := newSuperMAdminClient()
if err != nil {
@@ -179,7 +183,10 @@ func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.Logi
secretKey := utils.RandomCharString(32)
// obtain the configured MinIO region
// need it for user authentication
location := getConfiguredRegion(adminClient)
location, err := getConfiguredRegionForLogin(adminClient)
if err != nil {
return nil, err
}
// create user in MinIO
if _, err := addUser(ctx, adminClient, &accessKey, &secretKey, []string{}); err != nil {
log.Println("error adding user:", err)
@@ -207,8 +214,7 @@ func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.Logi
credentials := mcsCredentials{minioCredentials: creds}
jwt, err := login(credentials)
if err != nil {
log.Println("error login:", err)
return nil, errorGeneric
return nil, err
}
// serialize output
loginResponse := &models.LoginResponse{

View File

@@ -100,7 +100,7 @@ func TestLoginOauth2Auth(t *testing.T) {
return nil, errors.New("error")
}
if _, err := loginOauth2Auth(ctx, identityProvider, mockCode, mockState); funcAssert.Error(err) {
funcAssert.Equal("error", err.Error())
funcAssert.Equal("an error occurred, please try again", err.Error())
}
}
@@ -189,8 +189,8 @@ func Test_getConfiguredRegion(t *testing.T) {
for _, tt := range tests {
tt.mock()
t.Run(tt.name, func(t *testing.T) {
if got := getConfiguredRegion(tt.args.client); got != tt.want {
t.Errorf("getConfiguredRegion() = %v, want %v", got, tt.want)
if got, _ := getConfiguredRegionForLogin(tt.args.client); got != tt.want {
t.Errorf("getConfiguredRegionForLogin() = %v, want %v", got, tt.want)
}
})
}

View File

@@ -16,6 +16,8 @@
package restapi
import "os"
// DifferenceArrays returns the elements in `a` that aren't in `b`.
func DifferenceArrays(a, b []string) []string {
mb := make(map[string]struct{}, len(b))
@@ -54,3 +56,12 @@ func UniqueKeys(a []string) []string {
}
return list
}
// FileExists verifies if a file exist on the desired location and its not a folder
func FileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}