mirror of
https://github.com/minio/minio.git
synced 2025-11-07 12:52:58 -05:00
Add LDAP public key authentication to SFTP (#19833)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
// Copyright (c) 2015-2024 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
@@ -18,7 +18,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
@@ -29,18 +28,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
xldap "github.com/minio/pkg/v3/ldap"
|
||||
xsftp "github.com/minio/pkg/v3/sftp"
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type sftpLogger struct{}
|
||||
|
||||
func (s *sftpLogger) Info(tag xsftp.LogType, msg string) {
|
||||
logger.Info(msg)
|
||||
}
|
||||
|
||||
const (
|
||||
kexAlgoDH1SHA1 = "diffie-hellman-group1-sha1"
|
||||
kexAlgoDH14SHA1 = "diffie-hellman-group14-sha1"
|
||||
@@ -58,6 +54,16 @@ const (
|
||||
tripledescbcID = "3des-cbc"
|
||||
)
|
||||
|
||||
var (
|
||||
errSFTPPublicKeyBadFormat = errors.New("the public key provided could not be parsed")
|
||||
errSFTPUserHasNoPolicies = errors.New("no policies present on this account")
|
||||
errSFTPLDAPNotEnabled = errors.New("ldap authentication is not enabled")
|
||||
)
|
||||
|
||||
// if the sftp parameter --trusted-user-ca-key is set, then
|
||||
// the final form of the key file will be set as this variable.
|
||||
var caPublicKey ssh.PublicKey
|
||||
|
||||
// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.22.0:ssh/common.go;l=46
|
||||
// preferredKexAlgos specifies the default preference for key-exchange
|
||||
// algorithms in preference order. The diffie-hellman-group16-sha512 algorithm
|
||||
@@ -120,6 +126,226 @@ var supportedMACs = []string{
|
||||
"hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-sha1-96",
|
||||
}
|
||||
|
||||
func sshPubKeyAuth(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
return authenticateSSHConnection(c, key, nil)
|
||||
}
|
||||
|
||||
func sshPasswordAuth(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
||||
return authenticateSSHConnection(c, nil, pass)
|
||||
}
|
||||
|
||||
func authenticateSSHConnection(c ssh.ConnMetadata, key ssh.PublicKey, pass []byte) (*ssh.Permissions, error) {
|
||||
user, found := strings.CutSuffix(c.User(), "=ldap")
|
||||
if found {
|
||||
if !globalIAMSys.LDAPConfig.Enabled() {
|
||||
return nil, errSFTPLDAPNotEnabled
|
||||
}
|
||||
return processLDAPAuthentication(key, pass, user)
|
||||
}
|
||||
|
||||
user, found = strings.CutSuffix(c.User(), "=svc")
|
||||
if found {
|
||||
goto internalAuth
|
||||
}
|
||||
|
||||
if globalIAMSys.LDAPConfig.Enabled() {
|
||||
perms, _ := processLDAPAuthentication(key, pass, user)
|
||||
if perms != nil {
|
||||
return perms, nil
|
||||
}
|
||||
}
|
||||
|
||||
internalAuth:
|
||||
ui, ok := globalIAMSys.GetUser(context.Background(), user)
|
||||
if !ok {
|
||||
return nil, errNoSuchUser
|
||||
}
|
||||
|
||||
if caPublicKey != nil {
|
||||
err := validateKey(c, key)
|
||||
if err != nil {
|
||||
return nil, errAuthentication
|
||||
}
|
||||
} else {
|
||||
|
||||
// Temporary credentials are not allowed.
|
||||
if ui.Credentials.IsTemp() {
|
||||
return nil, errAuthentication
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(ui.Credentials.SecretKey), pass) != 1 {
|
||||
return nil, errAuthentication
|
||||
}
|
||||
}
|
||||
|
||||
return &ssh.Permissions{
|
||||
CriticalOptions: map[string]string{
|
||||
"AccessKey": ui.Credentials.AccessKey,
|
||||
"SecretKey": ui.Credentials.SecretKey,
|
||||
"SessionToken": ui.Credentials.SessionToken,
|
||||
},
|
||||
Extensions: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func processLDAPAuthentication(key ssh.PublicKey, pass []byte, user string) (perms *ssh.Permissions, err error) {
|
||||
var lookupResult *xldap.DNSearchResult
|
||||
var targetGroups []string
|
||||
|
||||
if pass == nil && key == nil {
|
||||
return nil, errAuthentication
|
||||
}
|
||||
|
||||
if pass != nil {
|
||||
sa, _, err := globalIAMSys.getServiceAccount(context.Background(), user)
|
||||
if err == nil {
|
||||
if subtle.ConstantTimeCompare([]byte(sa.Credentials.SecretKey), pass) != 1 {
|
||||
return nil, errAuthentication
|
||||
}
|
||||
|
||||
return &ssh.Permissions{
|
||||
CriticalOptions: map[string]string{
|
||||
"AccessKey": sa.Credentials.AccessKey,
|
||||
"SecretKey": sa.Credentials.SecretKey,
|
||||
"SessionToken": sa.Credentials.SessionToken,
|
||||
},
|
||||
Extensions: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, errNoSuchServiceAccount) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lookupResult, targetGroups, err = globalIAMSys.LDAPConfig.Bind(user, string(pass))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
} else if key != nil {
|
||||
|
||||
lookupResult, targetGroups, err = globalIAMSys.LDAPConfig.LookupUserDN(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if lookupResult == nil {
|
||||
return nil, errNoSuchUser
|
||||
}
|
||||
|
||||
ldapPolicies, _ := globalIAMSys.PolicyDBGet(lookupResult.NormDN, targetGroups...)
|
||||
if len(ldapPolicies) == 0 {
|
||||
return nil, errSFTPUserHasNoPolicies
|
||||
}
|
||||
|
||||
claims := make(map[string]interface{})
|
||||
for attribKey, attribValue := range lookupResult.Attributes {
|
||||
// we skip multi-value attributes here, as they cannot
|
||||
// be stored in the critical options.
|
||||
if len(attribValue) != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
if attribKey == "sshPublicKey" && key != nil {
|
||||
key2, _, _, _, err := ssh.ParseAuthorizedKey([]byte(attribValue[0]))
|
||||
if err != nil {
|
||||
return nil, errSFTPPublicKeyBadFormat
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare(key2.Marshal(), key.Marshal()) != 1 {
|
||||
return nil, errAuthentication
|
||||
}
|
||||
}
|
||||
claims[ldapAttribPrefix+attribKey] = attribValue[0]
|
||||
}
|
||||
|
||||
expiryDur, err := globalIAMSys.LDAPConfig.GetExpiryDuration("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims[expClaim] = UTCNow().Add(expiryDur).Unix()
|
||||
claims[ldapUserN] = user
|
||||
claims[ldapUser] = lookupResult.NormDN
|
||||
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(claims, globalActiveCred.SecretKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set the parent of the temporary access key, this is useful
|
||||
// in obtaining service accounts by this cred.
|
||||
cred.ParentUser = lookupResult.NormDN
|
||||
|
||||
// Set this value to LDAP groups, LDAP user can be part
|
||||
// of large number of groups
|
||||
cred.Groups = targetGroups
|
||||
|
||||
// Set the newly generated credentials, policyName is empty on purpose
|
||||
// LDAP policies are applied automatically using their ldapUser, ldapGroups
|
||||
// mapping.
|
||||
updatedAt, err := globalIAMSys.SetTempUser(context.Background(), cred.AccessKey, cred, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
replLogIf(context.Background(), globalSiteReplicationSys.IAMChangeHook(context.Background(), madmin.SRIAMItem{
|
||||
Type: madmin.SRIAMItemSTSAcc,
|
||||
STSCredential: &madmin.SRSTSCredential{
|
||||
AccessKey: cred.AccessKey,
|
||||
SecretKey: cred.SecretKey,
|
||||
SessionToken: cred.SessionToken,
|
||||
ParentUser: cred.ParentUser,
|
||||
},
|
||||
UpdatedAt: updatedAt,
|
||||
}))
|
||||
|
||||
return &ssh.Permissions{
|
||||
CriticalOptions: map[string]string{
|
||||
"AccessKey": cred.AccessKey,
|
||||
"SecretKey": cred.SecretKey,
|
||||
"SessionToken": cred.SessionToken,
|
||||
},
|
||||
Extensions: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateKey(c ssh.ConnMetadata, clientKey ssh.PublicKey) (err error) {
|
||||
if caPublicKey == nil {
|
||||
return errors.New("public key authority validation requested but no ca public key specified.")
|
||||
}
|
||||
|
||||
cert, ok := clientKey.(*ssh.Certificate)
|
||||
if !ok {
|
||||
return errSftpPublicKeyWithoutCert
|
||||
}
|
||||
|
||||
// ssh.CheckCert called by ssh.Authenticate accepts certificates
|
||||
// with empty principles list so we block those in here.
|
||||
if len(cert.ValidPrincipals) == 0 {
|
||||
return errSftpCertWithoutPrincipals
|
||||
}
|
||||
|
||||
// Verify that certificate provided by user is issued by trusted CA,
|
||||
// username in authentication request matches to identities in certificate
|
||||
// and that certificate type is correct.
|
||||
checker := ssh.CertChecker{}
|
||||
checker.IsUserAuthority = func(k ssh.PublicKey) bool {
|
||||
return subtle.ConstantTimeCompare(caPublicKey.Marshal(), k.Marshal()) == 1
|
||||
}
|
||||
|
||||
_, err = checker.Authenticate(c, clientKey)
|
||||
return
|
||||
}
|
||||
|
||||
type sftpLogger struct{}
|
||||
|
||||
func (s *sftpLogger) Info(tag xsftp.LogType, msg string) {
|
||||
logger.Info(msg)
|
||||
}
|
||||
|
||||
func (s *sftpLogger) Error(tag xsftp.LogType, err error) {
|
||||
switch tag {
|
||||
case xsftp.AcceptNetworkError:
|
||||
@@ -160,16 +386,19 @@ func filterAlgos(arg string, want []string, allowed []string) []string {
|
||||
|
||||
func startSFTPServer(args []string) {
|
||||
var (
|
||||
port int
|
||||
publicIP string
|
||||
sshPrivateKey string
|
||||
userCaKeyFile string
|
||||
port int
|
||||
publicIP string
|
||||
sshPrivateKey string
|
||||
userCaKeyFile string
|
||||
disablePassAuth bool
|
||||
)
|
||||
|
||||
allowPubKeys := supportedPubKeyAuthAlgos
|
||||
allowKexAlgos := preferredKexAlgos
|
||||
allowCiphers := preferredCiphers
|
||||
allowMACs := supportedMACs
|
||||
var err error
|
||||
|
||||
for _, arg := range args {
|
||||
tokens := strings.SplitN(arg, "=", 2)
|
||||
if len(tokens) != 2 {
|
||||
@@ -201,6 +430,8 @@ func startSFTPServer(args []string) {
|
||||
allowMACs = filterAlgos(arg, strings.Split(tokens[1], ","), supportedMACs)
|
||||
case "trusted-user-ca-key":
|
||||
userCaKeyFile = tokens[1]
|
||||
case "password-auth":
|
||||
disablePassAuth, _ = strconv.ParseBool(tokens[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +453,18 @@ func startSFTPServer(args []string) {
|
||||
logger.Fatal(fmt.Errorf("invalid arguments passed, private key file is not parseable: %v", err), "unable to start SFTP server")
|
||||
}
|
||||
|
||||
if userCaKeyFile != "" {
|
||||
keyBytes, err := os.ReadFile(userCaKeyFile)
|
||||
if err != nil {
|
||||
logger.Fatal(fmt.Errorf("invalid arguments passed, trusted user certificate authority public key file is not accessible: %v", err), "unable to start SFTP server")
|
||||
}
|
||||
|
||||
caPublicKey, _, _, _, err = ssh.ParseAuthorizedKey(keyBytes)
|
||||
if err != nil {
|
||||
logger.Fatal(fmt.Errorf("invalid arguments passed, trusted user certificate authority public key file is not parseable: %v", err), "unable to start SFTP server")
|
||||
}
|
||||
}
|
||||
|
||||
// An SSH server is represented by a ServerConfig, which holds
|
||||
// certificate details and handles authentication of ServerConns.
|
||||
sshConfig := &ssh.ServerConfig{
|
||||
@@ -231,116 +474,13 @@ func startSFTPServer(args []string) {
|
||||
MACs: allowMACs,
|
||||
},
|
||||
PublicKeyAuthAlgorithms: allowPubKeys,
|
||||
PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
||||
if globalIAMSys.LDAPConfig.Enabled() {
|
||||
sa, _, err := globalIAMSys.getServiceAccount(context.Background(), c.User())
|
||||
if err != nil && !errors.Is(err, errNoSuchServiceAccount) {
|
||||
return nil, err
|
||||
}
|
||||
if errors.Is(err, errNoSuchServiceAccount) {
|
||||
lookupResult, targetGroups, err := globalIAMSys.LDAPConfig.Bind(c.User(), string(pass))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetUser := lookupResult.NormDN
|
||||
ldapPolicies, _ := globalIAMSys.PolicyDBGet(targetUser, targetGroups...)
|
||||
if len(ldapPolicies) == 0 {
|
||||
return nil, errAuthentication
|
||||
}
|
||||
criticalOptions := map[string]string{
|
||||
ldapUser: targetUser,
|
||||
ldapActualUser: lookupResult.ActualDN,
|
||||
ldapUserN: c.User(),
|
||||
}
|
||||
for attribKey, attribValue := range lookupResult.Attributes {
|
||||
// we skip multi-value attributes here, as they cannot
|
||||
// be stored in the critical options.
|
||||
if len(attribValue) == 1 {
|
||||
criticalOptions[ldapAttribPrefix+attribKey] = attribValue[0]
|
||||
}
|
||||
}
|
||||
|
||||
return &ssh.Permissions{
|
||||
CriticalOptions: criticalOptions,
|
||||
Extensions: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(sa.Credentials.SecretKey), pass) == 1 {
|
||||
return &ssh.Permissions{
|
||||
CriticalOptions: map[string]string{
|
||||
"accessKey": c.User(),
|
||||
},
|
||||
Extensions: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
return nil, errAuthentication
|
||||
}
|
||||
|
||||
ui, ok := globalIAMSys.GetUser(context.Background(), c.User())
|
||||
if !ok {
|
||||
return nil, errNoSuchUser
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(ui.Credentials.SecretKey), pass) == 1 {
|
||||
return &ssh.Permissions{
|
||||
CriticalOptions: map[string]string{
|
||||
"accessKey": c.User(),
|
||||
},
|
||||
Extensions: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
return nil, errAuthentication
|
||||
},
|
||||
PublicKeyCallback: sshPubKeyAuth,
|
||||
}
|
||||
|
||||
if userCaKeyFile != "" {
|
||||
keyBytes, err := os.ReadFile(userCaKeyFile)
|
||||
if err != nil {
|
||||
logger.Fatal(fmt.Errorf("invalid arguments passed, trusted user certificate authority public key file is not accessible: %v", err), "unable to start SFTP server")
|
||||
}
|
||||
|
||||
caPublicKey, _, _, _, err := ssh.ParseAuthorizedKey(keyBytes)
|
||||
if err != nil {
|
||||
logger.Fatal(fmt.Errorf("invalid arguments passed, trusted user certificate authority public key file is not parseable: %v", err), "unable to start SFTP server")
|
||||
}
|
||||
|
||||
sshConfig.PublicKeyCallback = func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
_, ok := globalIAMSys.GetUser(context.Background(), c.User())
|
||||
if !ok {
|
||||
return nil, errNoSuchUser
|
||||
}
|
||||
|
||||
// Verify that client provided certificate, not only public key.
|
||||
cert, ok := key.(*ssh.Certificate)
|
||||
if !ok {
|
||||
return nil, errSftpPublicKeyWithoutCert
|
||||
}
|
||||
|
||||
// ssh.CheckCert called by ssh.Authenticate accepts certificates
|
||||
// with empty principles list so we block those in here.
|
||||
if len(cert.ValidPrincipals) == 0 {
|
||||
return nil, errSftpCertWithoutPrincipals
|
||||
}
|
||||
|
||||
// Verify that certificate provided by user is issued by trusted CA,
|
||||
// username in authentication request matches to identities in certificate
|
||||
// and that certificate type is correct.
|
||||
checker := ssh.CertChecker{}
|
||||
checker.IsUserAuthority = func(k ssh.PublicKey) bool {
|
||||
return bytes.Equal(k.Marshal(), caPublicKey.Marshal())
|
||||
}
|
||||
_, err = checker.Authenticate(c, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ssh.Permissions{
|
||||
CriticalOptions: map[string]string{
|
||||
"accessKey": c.User(),
|
||||
},
|
||||
Extensions: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
if !disablePassAuth {
|
||||
sshConfig.PasswordCallback = sshPasswordAuth
|
||||
} else {
|
||||
sshConfig.PasswordCallback = nil
|
||||
}
|
||||
|
||||
sshConfig.AddHostKey(private)
|
||||
|
||||
Reference in New Issue
Block a user