diff --git a/README.md b/README.md index c5627c02..48e6432b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Other supported formats are listed below. * `false` - Data sent between client and server is not encrypted beyond the login packet. (Default) * `true` - Data sent between client and server is encrypted. * `app name` - The application name (default is go-mssqldb) +* `authenticator` - Can be used to specify use of a registered authentication provider. (e.g. ntlm, winsspi (on windows) or krb5 (on linux)) ### Connection parameters for ODBC and ADO style connection strings @@ -59,12 +60,27 @@ Other supported formats are listed below. * `Workstation ID` - The workstation name (default is the host name) * `ApplicationIntent` - Can be given the value `ReadOnly` to initiate a read-only connection to an Availability Group listener. The `database` must be specified when connecting with `Application Intent` set to `ReadOnly`. +### Kerberos Active Directory authentication outside Windows +The package supports authentication via 3 methods. + +* Keytabs - Specify the username, keytab file, the krb5.conf file, and realm. + + authenticator=krb5;server=DatabaseServerName;database=DBName;user id=MyUserName;realm=domain.com;krb5conffile=/etc/krb5.conf;keytabfile=~/MyUserName.keytab + +* Credential Cache - Specify the krb5.conf file path and credential cache file path. + + authenticator=krb5;server=DatabaseServerName;database=DBName;krb5conffile=/etc/krb5.conf;krbcache=~/MyUserNameCachedCreds + +* Raw credentials - Specity krb5.confg, Username, Password and Realm. + + authenticator=krb5;server=DatabaseServerName;database=DBName;user id=MyUserName;password=MyPassword;realm=comani.com;krb5conffile=/etc/krb5.conf; + ### Kerberos Parameters -* `krb5conffile` - File path for kerberos configuration file. +* `krb5conffile` - path to kerberos configuration file. * `realm` - Domain name for kerberos authentication. -* `keytabfile` - Keytab file path. -* `krbcache` - Credential cache path. +* `keytabfile` - path to Keytab file. +* `krbcache` - path to Credential cache. * For further information on usage: * * diff --git a/auth.go b/auth.go deleted file mode 100644 index e2c725b8..00000000 --- a/auth.go +++ /dev/null @@ -1,82 +0,0 @@ -//go:build !windows && go1.13 -// +build !windows,go1.13 - -package mssql - -import ( - "fmt" - "io/ioutil" - "os" - - "github.com/jcmturner/gokrb5/v8/config" - "github.com/jcmturner/gokrb5/v8/credentials" - "github.com/jcmturner/gokrb5/v8/keytab" - "github.com/microsoft/go-mssqldb/msdsn" -) - -func getAuthN(user, password, serverSPN, workstation string, Kerberos *Kerberos) (auth auth, authOk bool) { - if Kerberos != nil && Kerberos.Config != nil { - auth, authOk = getKRB5Auth(user, serverSPN, Kerberos.Config, Kerberos.Keytab, Kerberos.Cache) - } else { - auth, authOk = getAuth(user, password, serverSPN, workstation) - } - return -} - -func getKrbParams(krb msdsn.KerberosConfig) (krbParams *Kerberos, err error) { - if krb.Krb5ConfFile != "" { - krbParams = &Kerberos{} - krbParams.Config, err = setupKerbConfig(krb.Krb5ConfFile) - if err != nil { - return nil, fmt.Errorf("cannot read kerberos config file: %w", err) - } - - if krb.KrbCache != "" { - krbParams.Cache, err = setupKerbCache(krb.KrbCache) - if err != nil { - return nil, fmt.Errorf("cannot read kerberos cache file: %w", err) - } - } - - if krb.KeytabFile != "" { - krbParams.Keytab, err = setupKerbKeytab(krb.KeytabFile) - if err != nil { - return nil, fmt.Errorf("cannot read kerberos keytab file: %w", err) - } - } - - } - return krbParams, nil -} - -func setupKerbConfig(krb5configPath string) (*config.Config, error) { - krb5CnfFile, err := os.Open(krb5configPath) - if err != nil { - return nil, err - } - c, err := config.NewFromReader(krb5CnfFile) - if err != nil { - return nil, err - } - return c, nil -} - -func setupKerbCache(kerbCCahePath string) (*credentials.CCache, error) { - cache, err := credentials.LoadCCache(kerbCCahePath) - if err != nil { - return nil, err - } - return cache, nil -} - -func setupKerbKeytab(keytabFilePath string) (*keytab.Keytab, error) { - var kt = &keytab.Keytab{} - keytabConf, err := ioutil.ReadFile(keytabFilePath) - if err != nil { - return nil, err - } - if err = kt.Unmarshal([]byte(keytabConf)); err != nil { - return nil, err - } - return kt, nil -} diff --git a/auth_test.go b/auth_test.go deleted file mode 100644 index ab9c89cb..00000000 --- a/auth_test.go +++ /dev/null @@ -1,40 +0,0 @@ -//go:build !windows && go1.13 -// +build !windows,go1.13 - -package mssql - -import ( - "io/ioutil" - "os" - "testing" - - "github.com/microsoft/go-mssqldb/msdsn" -) - -func TestGetKrbParams(t *testing.T) { - tempFile := createTempFile(t) - defer os.Remove(tempFile) - - krbParams := msdsn.KerberosConfig{ - Realm: "", - Krb5ConfFile: tempFile, - KeytabFile: tempFile, - KrbCache: "path/to/cache", - } - - _, err := getKrbParams(krbParams) - if err == nil { - t.Errorf("Error expected") - } -} - -func createTempFile(t *testing.T) string { - file, err := ioutil.TempFile("", "test-*.txt") - if err != nil { - t.Fatalf("Failed to create a temp file:%v", err) - } - if _, err := file.Write([]byte("This is a test file\n")); err != nil { - t.Fatalf("Failed to write file:%v", err) - } - return file.Name() -} diff --git a/integratedauth/krb5/krb5.go b/integratedauth/krb5/krb5.go new file mode 100644 index 00000000..6d968714 --- /dev/null +++ b/integratedauth/krb5/krb5.go @@ -0,0 +1,268 @@ +//go:build !windows && go1.13 +// +build !windows,go1.13 + +package krb5 + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "strconv" + "strings" + + "github.com/jcmturner/gokrb5/v8/client" + "github.com/jcmturner/gokrb5/v8/config" + "github.com/jcmturner/gokrb5/v8/credentials" + "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/jcmturner/gokrb5/v8/spnego" + "github.com/microsoft/go-mssqldb/integratedauth" + "github.com/microsoft/go-mssqldb/msdsn" +) + +var ( + SetKrbConfig = setupKerbConfig + SetKrbKeytab = setupKerbKeytab + SetKrbCache = setupKerbCache +) + +// Kerberos Client State +type krb5ClientState int + +type krb5Auth struct { + username string + password string + realm string + serverSPN string + port uint64 + krb5Config *config.Config + krbKeytab *keytab.Keytab + krbCache *credentials.CCache + krb5Client *client.Client + state krb5ClientState +} + +type Kerberos struct { + // Kerberos configuration details + Config *config.Config + + // Credential cache + Cache *credentials.CCache + + // A Kerberos realm is the domain over which a Kerberos authentication server has the authority + // to authenticate a user, host or service. + Realm string + + // Kerberos keytab that stores long-term keys for one or more principals + Keytab *keytab.Keytab +} + +const ( + // Initiator states + initiatorStart krb5ClientState = iota + initiatorWaitForMutal = iota + 2 + initiatorReady +) + +var ( + _ integratedauth.IntegratedAuthenticator = (*krb5Auth)(nil) + AuthProviderFunc integratedauth.Provider = integratedauth.ProviderFunc(getAuth) +) + +func init() { + err := integratedauth.SetIntegratedAuthenticationProvider("krb5", AuthProviderFunc) + if err != nil { + panic(err) + } +} + +func getAuth(config msdsn.Config) (integratedauth.IntegratedAuthenticator, error) { + var port uint64 + var realm, serviceStr string + var err error + + krb, err := readKrb5Config(config) + if err != nil { + return &krb5Auth{}, err + } + params1 := strings.Split(config.ServerSPN, ":") + if len(params1) != 2 { + return nil, errors.New("invalid ServerSPN") + } + + params2 := strings.Split(params1[1], "@") + switch len(params2) { + case 1: + port, err = strconv.ParseUint(params1[1], 10, 16) + if err != nil { + return nil, err + } + case 2: + port, err = strconv.ParseUint(params2[0], 10, 16) + if err != nil { + return nil, err + } + default: + return nil, errors.New("invalid ServerSPN") + } + + params3 := strings.Split(config.ServerSPN, "@") + switch len(params3) { + case 1: + serviceStr = params3[0] + params3 = strings.Split(params1[0], "/") + params3 = strings.Split(params3[1], ".") + realm = params3[1] + "." + params3[2] + case 2: + realm = params3[1] + serviceStr = params3[0] + default: + return nil, errors.New("invalid ServerSPN") + } + + return &krb5Auth{ + username: config.User, + password: config.Password, + serverSPN: serviceStr, + port: port, + realm: realm, + krb5Config: krb.Config, + krbKeytab: krb.Keytab, + krbCache: krb.Cache, + }, nil +} + +func (auth *krb5Auth) InitialBytes() ([]byte, error) { + var cl *client.Client + var err error + // Init keytab from conf + if auth.username != "" && auth.password != "" { + cl = client.NewWithPassword(auth.username, auth.realm, auth.password, auth.krb5Config) + } else if auth.krbKeytab != nil { + // Init krb5 client and login + cl = client.NewWithKeytab(auth.username, auth.realm, auth.krbKeytab, auth.krb5Config, client.DisablePAFXFAST(true)) + } else { + cl, err = client.NewFromCCache(auth.krbCache, auth.krb5Config) + if err != nil { + return []byte{}, err + } + } + auth.krb5Client = cl + auth.state = initiatorStart + tkt, sessionKey, err := cl.GetServiceTicket(auth.serverSPN) + if err != nil { + return []byte{}, err + } + + negTok, err := spnego.NewNegTokenInitKRB5(auth.krb5Client, tkt, sessionKey) + if err != nil { + return []byte{}, err + } + + outToken, err := negTok.Marshal() + if err != nil { + return []byte{}, err + } + auth.state = initiatorWaitForMutal + return outToken, nil +} + +func (auth *krb5Auth) Free() { + auth.krb5Client.Destroy() +} + +func (auth *krb5Auth) NextBytes(token []byte) ([]byte, error) { + var spnegoToken spnego.SPNEGOToken + if err := spnegoToken.Unmarshal(token); err != nil { + err := fmt.Errorf("unmarshal APRep token failed: %w", err) + return []byte{}, err + } + auth.state = initiatorReady + return []byte{}, nil +} + +func readKrb5Config(config msdsn.Config) (Kerberos, error) { + krb := Kerberos{} + var err error + + krbConfig, ok := config.Parameters["krb5conffile"] + if !ok { + return krb, fmt.Errorf("krb5 config file is required") + } + + krb.Config, err = SetKrbConfig(krbConfig) + if err != nil { + return krb, err + } + + missingParam := validateKerbConfig(config.Parameters) + if missingParam != "" { + return krb, fmt.Errorf("missing parameter:%s", missingParam) + } + + if realm, ok := config.Parameters["realm"]; ok { + krb.Realm = realm + } + + if krbCache, ok := config.Parameters["krbcache"]; ok { + krb.Cache, err = SetKrbCache(krbCache) + if err != nil { + return krb, err + } + } + + if keytabfile, ok := config.Parameters["keytabfile"]; ok { + krb.Keytab, err = SetKrbKeytab(keytabfile) + if err != nil { + return krb, err + } + } + + return krb, nil +} + +func validateKerbConfig(c map[string]string) (missingParam string) { + if c["keytabfile"] != "" { + if c["realm"] == "" { + missingParam = "realm" + return + } + } + if c["krbcache"] == "" && c["keytabfile"] == "" { + missingParam = "atleast krbcache or keytab is required" + return + } + return +} + +func setupKerbConfig(krb5configPath string) (*config.Config, error) { + krb5CnfFile, err := os.Open(krb5configPath) + if err != nil { + return nil, err + } + c, err := config.NewFromReader(krb5CnfFile) + if err != nil { + return nil, err + } + return c, nil +} + +func setupKerbCache(kerbCCahePath string) (*credentials.CCache, error) { + cache, err := credentials.LoadCCache(kerbCCahePath) + if err != nil { + return nil, err + } + return cache, nil +} + +func setupKerbKeytab(keytabFilePath string) (*keytab.Keytab, error) { + var kt = &keytab.Keytab{} + keytabConf, err := ioutil.ReadFile(keytabFilePath) + if err != nil { + return nil, err + } + if err = kt.Unmarshal([]byte(keytabConf)); err != nil { + return nil, err + } + return kt, nil +} diff --git a/kerbauth_test.go b/integratedauth/krb5/krb5_test.go similarity index 55% rename from kerbauth_test.go rename to integratedauth/krb5/krb5_test.go index 471d3b85..5da6596d 100644 --- a/kerbauth_test.go +++ b/integratedauth/krb5/krb5_test.go @@ -1,9 +1,10 @@ //go:build !windows && go1.13 // +build !windows,go1.13 -package mssql +package krb5 import ( + "io/ioutil" "reflect" "testing" @@ -11,12 +12,39 @@ import ( "github.com/jcmturner/gokrb5/v8/config" "github.com/jcmturner/gokrb5/v8/credentials" "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/microsoft/go-mssqldb/integratedauth" + "github.com/microsoft/go-mssqldb/msdsn" ) func TestGetAuth(t *testing.T) { kerberos := getKerberos() + var err error + configParams := msdsn.Config{ + User: "", + ServerSPN: "MSSQLSvc/mssql.domain.com:1433", + Port: 1433, + Parameters: map[string]string{ + "krb5conffile": "krb5conffile", + "keytabfile": "keytabfile", + "krbcache": "krbcache", + "realm": "domain.com", + }, + } + + SetKrbConfig = func(krb5configPath string) (*config.Config, error) { + return &config.Config{}, nil + } + SetKrbKeytab = func(keytabFilePath string) (*keytab.Keytab, error) { + return &keytab.Keytab{}, nil + } + SetKrbCache = func(kerbCCahePath string) (*credentials.CCache, error) { + return &credentials.CCache{}, nil + } - got, _ := getAuthN("", "", "MSSQLSvc/mssql.domain.com:1433", "", kerberos) + got, err := getAuth(configParams) + if err != nil { + t.Errorf("failed:%v", err) + } kt := &krb5Auth{username: "", realm: "domain.com", serverSPN: "MSSQLSvc/mssql.domain.com:1433", @@ -31,27 +59,16 @@ func TestGetAuth(t *testing.T) { t.Errorf("Failed to get correct krb5Auth object\nExpected:%v\nRecieved:%v", kt, got) } - got, _ = getAuthN("", "", "MSSQLSvc/mssql.domain.com:1433", "", kerberos) - kt = &krb5Auth{username: "", - realm: "domain.com", - serverSPN: "MSSQLSvc/mssql.domain.com:1433", - port: 1433, - krb5Config: kerberos.Config, - krbKeytab: kerberos.Keytab, - krbCache: kerberos.Cache, - state: 0} + configParams.ServerSPN = "MSSQLSvc/mssql.domain.com" - res = reflect.DeepEqual(got, kt) - if !res { - t.Errorf("Failed to get correct krb5Auth object\nExpected:%v\nRecieved:%v", kt, got) - } - - _, val := getAuthN("", "", "MSSQLSvc/mssql.domain.com", "", kerberos) - if val { + _, val := getAuth(configParams) + if val == nil { t.Errorf("Failed to get correct krb5Auth object: no port defined") } - got, _ = getAuthN("", "", "MSSQLSvc/mssql.domain.com:1433@DOMAIN.COM", "", kerberos) + configParams.ServerSPN = "MSSQLSvc/mssql.domain.com:1433@DOMAIN.COM" + + got, _ = getAuth(configParams) kt = &krb5Auth{username: "", realm: "DOMAIN.COM", serverSPN: "MSSQLSvc/mssql.domain.com:1433", @@ -66,18 +83,21 @@ func TestGetAuth(t *testing.T) { t.Errorf("Failed to get correct krb5Auth object\nExpected:%v\nRecieved:%v", kt, got) } - _, val = getAuthN("", "", "MSSQLSvc/mssql.domain.com:1433@domain.com@test", "", kerberos) - if val { + configParams.ServerSPN = "MSSQLSvc/mssql.domain.com:1433@domain.com@test" + _, val = getAuth(configParams) + if val == nil { t.Errorf("Failed to get correct krb5Auth object due to incorrect serverSPN name") } - _, val = getAuthN("", "", "MSSQLSvc/mssql.domain.com:port@domain.com", "", kerberos) - if val { + configParams.ServerSPN = "MSSQLSvc/mssql.domain.com:port@domain.com" + _, val = getAuth(configParams) + if val == nil { t.Errorf("Failed to get correct krb5Auth object due to incorrect port") } - _, val = getAuthN("", "", "MSSQLSvc/mssql.domain.com:port", "", kerberos) - if val { + configParams.ServerSPN = "MSSQLSvc/mssql.domain.com:port" + _, val = getAuth(configParams) + if val == nil { t.Errorf("Failed to get correct krb5Auth object due to incorrect port") } } @@ -111,7 +131,7 @@ func TestNextBytes(t *testing.T) { ans := []byte{} kerberos := getKerberos() - var krbObj auth = &krb5Auth{username: "", + var krbObj integratedauth.IntegratedAuthenticator = &krb5Auth{username: "", realm: "domain.com", serverSPN: "MSSQLSvc/mssql.domain.com:1433", port: 1433, @@ -133,7 +153,7 @@ func TestFree(t *testing.T) { cl := client.NewWithKeytab("Administrator", "DOMAIN.COM", kt, c, client.DisablePAFXFAST(true)) - var krbObj auth = &krb5Auth{username: "", + var krbObj integratedauth.IntegratedAuthenticator = &krb5Auth{username: "", realm: "domain.com", serverSPN: "MSSQLSvc/mssql.domain.com:1433", port: 1433, @@ -150,6 +170,30 @@ func TestFree(t *testing.T) { } } +func TestSetKrbConfig(t *testing.T) { + krb5conffile := createTempFile(t, "krb5conffile") + _, err := setupKerbConfig(krb5conffile) + if err != nil { + t.Errorf("Failed to read krb5 config file") + } +} + +func TestSetKrbKeytab(t *testing.T) { + krbkeytab := createTempFile(t, "keytabfile") + _, err := setupKerbKeytab(krbkeytab) + if err == nil { + t.Errorf("Failed to read keytab file") + } +} + +func TestSetKrbCache(t *testing.T) { + krbcache := createTempFile(t, "krbcache") + _, err := setupKerbCache(krbcache) + if err == nil { + t.Errorf("Failed to read cache file") + } +} + func getKerberos() (krbParams *Kerberos) { krbParams = &Kerberos{ Config: &config.Config{}, @@ -158,3 +202,14 @@ func getKerberos() (krbParams *Kerberos) { } return } + +func createTempFile(t *testing.T, filename string) string { + file, err := ioutil.TempFile("", "test-"+filename+".txt") + if err != nil { + t.Fatalf("Failed to create a temp file:%v", err) + } + if _, err := file.Write([]byte("This is a test file\n")); err != nil { + t.Fatalf("Failed to write file:%v", err) + } + return file.Name() +} diff --git a/kerbauth.go b/kerbauth.go deleted file mode 100644 index 7a628bed..00000000 --- a/kerbauth.go +++ /dev/null @@ -1,151 +0,0 @@ -//go:build !windows && go1.13 -// +build !windows,go1.13 - -package mssql - -import ( - "fmt" - "strconv" - "strings" - - "github.com/jcmturner/gokrb5/v8/client" - "github.com/jcmturner/gokrb5/v8/config" - "github.com/jcmturner/gokrb5/v8/credentials" - "github.com/jcmturner/gokrb5/v8/keytab" - "github.com/jcmturner/gokrb5/v8/spnego" -) - -// Kerberos Client State -type krb5ClientState int - -type krb5Auth struct { - username string - realm string - serverSPN string - port uint64 - krb5Config *config.Config - krbKeytab *keytab.Keytab - krbCache *credentials.CCache - krb5Client *client.Client - state krb5ClientState -} - -type Kerberos struct { - // Kerberos configuration details - Config *config.Config - - // Credential cache - Cache *credentials.CCache - - // A Kerberos realm is the domain over which a Kerberos authentication server has the authority - // to authenticate a user, host or service. - Realm string - - // Kerberos keytab that stores long-term keys for one or more principals - Keytab *keytab.Keytab -} - -const ( - // Initiator states - initiatorStart krb5ClientState = iota - initiatorWaitForMutal = iota + 2 - initiatorReady -) - -func getKRB5Auth(user, serverSPN string, krb5Conf *config.Config, keytabContent *keytab.Keytab, cacheContent *credentials.CCache) (auth, bool) { - var port uint64 - var realm, serviceStr string - var err error - - params1 := strings.Split(serverSPN, ":") - if len(params1) != 2 { - return nil, false - } - - params2 := strings.Split(params1[1], "@") - switch len(params2) { - case 1: - port, err = strconv.ParseUint(params1[1], 10, 16) - if err != nil { - return nil, false - } - case 2: - port, err = strconv.ParseUint(params2[0], 10, 16) - if err != nil { - return nil, false - } - default: - return nil, false - } - - params3 := strings.Split(serverSPN, "@") - switch len(params3) { - case 1: - serviceStr = params3[0] - params3 = strings.Split(params1[0], "/") - params3 = strings.Split(params3[1], ".") - realm = params3[1] + "." + params3[2] - case 2: - realm = params3[1] - serviceStr = params3[0] - default: - return nil, false - } - - return &krb5Auth{ - username: user, - serverSPN: serviceStr, - port: port, - realm: realm, - krb5Config: krb5Conf, - krbKeytab: keytabContent, - krbCache: cacheContent, - }, true -} - -func (auth *krb5Auth) InitialBytes() ([]byte, error) { - var cl *client.Client - var err error - // Init keytab from conf - if auth.krbKeytab != nil { - // Init krb5 client and login - cl = client.NewWithKeytab(auth.username, auth.realm, auth.krbKeytab, auth.krb5Config, client.DisablePAFXFAST(true)) - } else { - cl, err = client.NewFromCCache(auth.krbCache, auth.krb5Config) - if err != nil { - return []byte{}, err - } - } - auth.krb5Client = cl - auth.state = initiatorStart - tkt, sessionKey, err := cl.GetServiceTicket(auth.serverSPN) - if err != nil { - return []byte{}, err - } - - negTok, err := spnego.NewNegTokenInitKRB5(auth.krb5Client, tkt, sessionKey) - if err != nil { - return []byte{}, err - } - - outToken, err := negTok.Marshal() - if err != nil { - return []byte{}, err - } - auth.state = initiatorWaitForMutal - return outToken, nil -} - -func (auth *krb5Auth) Free() { - auth.krb5Client.Destroy() -} - -func (auth *krb5Auth) NextBytes(token []byte) ([]byte, error) { - var spnegoToken spnego.SPNEGOToken - if err := spnegoToken.Unmarshal(token); err != nil { - err := fmt.Errorf("unmarshal APRep token failed: %w", err) - return []byte{}, err - } - auth.state = initiatorReady - return []byte{}, nil -}