diff --git a/.circleci/config.yml b/.circleci/config.yml index c6c827f..a24215a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,15 +5,15 @@ version: 2 jobs: build: docker: - - image: circleci/golang:1.12.5 - - environment: GO111MODULE=on + - image: circleci/golang:latest working_directory: /go/src/github.com/nikogura/dbt steps: - checkout - - # specify any bash command here prefixed with `run: ` + - run: echo "${TEST_PRIVATE_KEY}" | base64 -d > ~/.ssh/id_rsa.test + - run: chmod 700 ~/.ssh/id_rsa.test + - run: echo "${TEST_PUBLIC_KEY}" | base64 -d > ~/.ssh/id_rsa.test.pub + - run: ssh-add ~/.ssh/id_rsa.test - run: gpg-agent --daemon - run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... - run: bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md index 30d2f15..1463205 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,13 @@ Output: An http repository server. It serves up the various dbt tools and components from a file location on disk. -At present, the repo server supports basic auth via _htpasswd_ file. Other auth options will be available as time allows. +At present, the repo server supports basic auth via _htpasswd_ file and public key auth from an ssh-agent via JWT. Other auth options will be available as time allows. + +Separate IDP (Identity Provider) files can be provided to provide privilege separation between tool use (GET) and tool publishing (PUT). Likewise separate auth methods can be used for GET and PUT. + +Why did I make it possible to have split auth methods? Flexibility. Passwordless ssh-key auth for a user is good UX. It's secure, and easy for the users. It's kind of a pain for CI systems and other automated uses. Sometimes just sticking a password in the environment is the best way for these use cases. Hey, do what you want. I'm just trying to help. + +The PublicKey Auth IDP file contains sections for both GET and PUT, so a single file can be used for both. Obviously if you do use separate files, only the appropriate portion of each file will be read. ### Reposerver Config @@ -301,13 +307,38 @@ A JSON file of the form: "address": "my-hostname.com", "port": 443, "serverRoot": "/path/to/where/you/store/tools", - "authType": "basic-htpasswd", + "authTypeGet": "basic-htpasswd", + "authTypePut": "basic-htpasswd", "authGets": false, - "authOpts": { - "idpFile": "/path/to/htpasswd/file" - } + "authOptsGet": { + "idpFile": "/path/to/htpasswd/file/for/gets" + }, + "authOptsPutt": { + "idpFile": "/path/to/htpasswd/file/for/puts" + }, } +### Reposerver IDP File + +The reposerver takes an IDP file. In the case of http basic auth, this is a standard htpasswd file. + +In the case of Public Key JWT Auth, it looks like so: + + { + "getUsers": [ + { + "username": "foo", + "publickey": "ssh-rsa ...... foo@example.com" + } + ], + "putUsers": [ + { + "username": "bar", + "publickey": "ssh-rsa ...... bar@example.com" + } + ] + } + ### Running the Reposerver Command: `dbt reposerver -f /path/to/config` diff --git a/metadata.json b/metadata.json index 2476321..d5c3801 100644 --- a/metadata.json +++ b/metadata.json @@ -1,5 +1,5 @@ { - "version": "3.1.3", + "version": "3.2.0", "package": "github.com/nikogura/dbt", "description": "Dynamic Binary Toolkit - A framework for running self-updating signed binaries from a central, trusted repository.", "repository": "http://localhost:8081/artifactory/dbt", diff --git a/pkg/dbt/dbt.go b/pkg/dbt/dbt.go index 23f0fbe..ac567db 100644 --- a/pkg/dbt/dbt.go +++ b/pkg/dbt/dbt.go @@ -579,7 +579,7 @@ func (dbt *DBT) runExec(homedir string, args []string) (err error) { return err } -// VerboseOutput Covenience function so I don't have to write 'if verbose {...}' all the time. +// VerboseOutput Convenience function so I don't have to write 'if verbose {...}' all the time. func (dbt *DBT) VerboseOutput(message string, args ...interface{}) { if dbt.Verbose { if len(args) == 0 { diff --git a/pkg/dbt/reposerver.go b/pkg/dbt/reposerver.go index f65bbcf..31be865 100644 --- a/pkg/dbt/reposerver.go +++ b/pkg/dbt/reposerver.go @@ -6,6 +6,7 @@ import ( auth "github.com/abbot/go-http-auth" "github.com/gorilla/mux" "github.com/nikogura/gomason/pkg/gomason" + "github.com/orion-labs/jwt-ssh-agent-go/pkg/agentjwt" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "io" @@ -37,12 +38,14 @@ func init() { // DBTRepoServer The reference 'trusted repository' server for dbt. type DBTRepoServer struct { - Address string `json:"address"` - Port int `json:"port"` - ServerRoot string `json:"serverRoot"` - AuthType string `json:"authType"` - AuthGets bool `json:"authGets"` - AuthOpts AuthOpts `json:"authOpts"` + Address string `json:"address"` + Port int `json:"port"` + ServerRoot string `json:"serverRoot"` + AuthTypeGet string `json:"authTypeGet"` + AuthTypePut string `json:"authTypePut"` + AuthGets bool `json:"authGets"` + AuthOptsGet AuthOpts `json:"authOptsGet"` + AuthOptsPut AuthOpts `json:"authOptsPut"` } // AuthOpts Struct for holding Auth options @@ -79,16 +82,16 @@ func (d *DBTRepoServer) RunRepoServer() (err error) { r := mux.NewRouter() // handle the uploads if enabled - if d.AuthType != "" { - switch d.AuthType { + if d.AuthTypePut != "" { + switch d.AuthTypePut { case AUTH_BASIC_HTPASSWD: - htpasswd := auth.HtpasswdFileProvider(d.AuthOpts.IdpFile) + htpasswd := auth.HtpasswdFileProvider(d.AuthOptsPut.IdpFile) authenticator := auth.NewBasicAuthenticator("DBT Server", htpasswd) r.PathPrefix("/").HandlerFunc(authenticator.Wrap(d.PutHandlerHtpasswd)).Methods("PUT") - //case AUTH_SSH_AGENT_FILE: - // r.PathPrefix("/").HandlerFunc(d.PutHandlerPubkeyFile).Methods("PUT") - //case AUTH_SSH_AGENT_FUNC: - // r.PathPrefix("/").HandlerFunc(d.PutHandlerPubkeyFunc).Methods("PUT") + case AUTH_SSH_AGENT_FILE: + r.PathPrefix("/").HandlerFunc(d.PutHandlerPubkeyFile).Methods("PUT") + case AUTH_SSH_AGENT_FUNC: + r.PathPrefix("/").HandlerFunc(d.PutHandlerPubkeyFunc).Methods("PUT") //case AUTH_BASIC_LDAP: // err = errors.New("basic auth via ldap not yet supported") // return err @@ -96,26 +99,24 @@ func (d *DBTRepoServer) RunRepoServer() (err error) { // err = errors.New("ssh-agent auth via ldap not yet supported") // return err default: - err = errors.New(fmt.Sprintf("unsupported auth method: %s", d.AuthType)) + err = errors.New(fmt.Sprintf("unsupported auth method: %s", d.AuthTypePut)) return err } } // handle the downloads and indices - if d.AuthType != "" && d.AuthGets { - switch d.AuthType { + if d.AuthTypeGet != "" && d.AuthGets { + switch d.AuthTypeGet { case AUTH_BASIC_HTPASSWD: - htpasswd := auth.HtpasswdFileProvider(d.AuthOpts.IdpFile) + htpasswd := auth.HtpasswdFileProvider(d.AuthOptsGet.IdpFile) authenticator := auth.NewBasicAuthenticator("DBT Server", htpasswd) r.PathPrefix("/").Handler(auth.JustCheck(authenticator, http.FileServer(http.Dir(d.ServerRoot)).ServeHTTP)).Methods("GET", "HEAD") - //case AUTH_SSH_AGENT_FILE: - // err = errors.New("pubkey auth via file not yet supported") - // return err - // - //case AUTH_SSH_AGENT_FUNC: - // err = errors.New("pubkey auth via file not yet supported") - // return err - // + case AUTH_SSH_AGENT_FILE: + r.PathPrefix("/").Handler(d.CheckPubkeysGetFile(http.FileServer(http.Dir(d.ServerRoot)).ServeHTTP)).Methods("GET", "HEAD") + + case AUTH_SSH_AGENT_FUNC: + r.PathPrefix("/").Handler(d.CheckPubkeysGetFunc(http.FileServer(http.Dir(d.ServerRoot)).ServeHTTP)).Methods("GET", "HEAD") + //case AUTH_BASIC_LDAP: // err = errors.New("basic auth via ldap not yet supported") // return err @@ -125,7 +126,7 @@ func (d *DBTRepoServer) RunRepoServer() (err error) { // return err // default: - err = errors.New(fmt.Sprintf("unsupported auth method: %s", d.AuthType)) + err = errors.New(fmt.Sprintf("unsupported auth method: %s", d.AuthTypeGet)) return err } @@ -206,84 +207,259 @@ func (d *DBTRepoServer) PutHandlerHtpasswd(w http.ResponseWriter, r *auth.Authen w.WriteHeader(http.StatusCreated) } -//// PubkeyFromFile takes a subject name, and pulls the corresponding pubkey out of the identity provider file -//func (d *DBTRepoServer) PubkeyFromFile(subject string) (pubkey string, err error) { -// // need to get pubkey file similar to: htpasswd := PubkeyFileProvider(d.AuthOpts.IdpFile) -// return pubkey, err -//} -// -//// PubkeyFromFunc takes a subject name, and runs the configured function to return the corresponding public key -//func (d *DBTRepoServer) PubkeyFromFunc(subject string) (pubkey string, err error) { -// -// return pubkey, err -//} -// -//func (d *DBTRepoServer) PubkeyAuth(subject string, authFunc func(subject string) (pubkey string, err error)) (principal string) { -// -// return principal -//} -// -//// PutHandlerPubKeyFile -//func (d *DBTRepoServer) PutHandlerPubkeyFile(w http.ResponseWriter, r *http.Request) { -// tokenString := r.Header.Get("Token") -// -// // Parse the token, which includes setting up it's internals so it can be verified. -// subject, token, err := agentjwt.ParsePubkeySignedToken(tokenString, d.PubkeyFromFile) -// if err != nil { -// log.Errorf("Error: %s", err) -// w.WriteHeader(http.StatusBadRequest) -// return -// } -// -// if !token.Valid { -// log.Info("Auth Failed") -// w.WriteHeader(http.StatusUnauthorized) -// } -// -// log.Infof("Subject %s successfully authenticated", subject) -// -// err = d.HandlePut(r.URL.Path, r.Body, r.Header.Get("X-Checksum-Md5"), r.Header.Get("X-Checksum-Sha1"), r.Header.Get("X-Checksum-Sha256")) -// if err != nil { -// err = errors.Wrapf(err, "failed writing file %s", r.URL.Path) -// w.WriteHeader(http.StatusInternalServerError) -// log.Error(err) -// return -// } -// -// w.WriteHeader(http.StatusCreated) -//} -// -//// PutHandlerPubkeyFunc -//func (d *DBTRepoServer) PutHandlerPubkeyFunc(w http.ResponseWriter, r *http.Request) { -// tokenString := r.Header.Get("Token") -// -// // sanity check username -// -// //Parse the token, which includes setting up it's internals so it can be verified. -// subject, token, err := agentjwt.ParsePubkeySignedToken(tokenString, d.PubkeyFromFunc) -// if err != nil { -// log.Errorf("Error: %s", err) -// w.WriteHeader(http.StatusBadRequest) -// return -// } -// -// if !token.Valid { -// log.Info("Auth Failed") -// w.WriteHeader(http.StatusUnauthorized) -// } -// -// log.Infof("Subject %s successfully authenticated", subject) -// -// err = d.HandlePut(r.URL.Path, r.Body, r.Header.Get("X-Checksum-Md5"), r.Header.Get("X-Checksum-Sha1"), r.Header.Get("X-Checksum-Sha256")) -// if err != nil { -// err = errors.Wrapf(err, "failed writing file %s", r.URL.Path) -// w.WriteHeader(http.StatusInternalServerError) -// log.Error(err) -// return -// } -// -// w.WriteHeader(http.StatusCreated) -//} +// PubkeyUser A representation of a user permitted to authenticate via public key. PubkeyUsers will have at minimum a Username, and a list of authorized public keys. +type PubkeyUser struct { + Username string `json:"username"` + AuthorizedKey string `json:"publickey"` +} + +// PubkeyIdpFile A representation of a public key IDP (Identity Provider) file. Will have a list of users allowed to GET and a list of users authorized to PUT. +type PubkeyIdpFile struct { + GetUsers []PubkeyUser `json:"getUsers"` + PutUsers []PubkeyUser `json:"putUsers"` +} + +// LoadPubkeyIdpFile Loads a public key IDP JSON file. +func LoadPubkeyIdpFile(filePath string) (pkidp PubkeyIdpFile, err error) { + fileData, err := ioutil.ReadFile(filePath) + if err != nil { + err = errors.Wrapf(err, "failed to read idp file %s", filePath) + return pkidp, err + } + + err = json.Unmarshal(fileData, &pkidp) + if err != nil { + err = errors.Wrapf(err, "failed to unmarshal data in %s to PubkeyIdpFile", filePath) + return pkidp, err + } + + return pkidp, err +} + +/* +Sample Pubkey IDP File +{ + "getUsers": [ + { + "username": "foo", + "publickey": "" + } + ], + "putUsers": [ + { + "username": "bar", + "publickey": "" + } + ] +} +*/ + +// PubkeyFromFilePut takes a subject name, and pulls the corresponding pubkey out of the identity provider file for puts +func (d *DBTRepoServer) PubkeyFromFilePut(subject string) (pubkeys string, err error) { + idpFile, err := LoadPubkeyIdpFile(d.AuthOptsPut.IdpFile) + if err != nil { + err = errors.Wrapf(err, "failed loading PUT IDP file%s", d.AuthOptsPut.IdpFile) + return pubkeys, err + } + + for _, u := range idpFile.PutUsers { + if u.Username == subject { + pubkeys = u.AuthorizedKey + log.Printf("Returning put key %q\n", pubkeys) + return pubkeys, err + } + } + + err = errors.New(fmt.Sprintf("pubkey not found for %s", subject)) + + return pubkeys, err +} + +// PubkeyFromFileGet takes a subject name, and pulls the corresponding pubkey out of the identity provider file for puts +func (d *DBTRepoServer) PubkeyFromFileGet(subject string) (pubkeys string, err error) { + idpFile, err := LoadPubkeyIdpFile(d.AuthOptsGet.IdpFile) + if err != nil { + err = errors.Wrapf(err, "failed loading GET IDP file%s", d.AuthOptsGet.IdpFile) + return pubkeys, err + } + + for _, u := range idpFile.GetUsers { + if u.Username == subject { + pubkeys = u.AuthorizedKey + log.Printf("Returning get key %q\n", pubkeys) + return pubkeys, err + } + } + err = errors.New(fmt.Sprintf("pubkey not found for %s", subject)) + + return pubkeys, err +} + +// PubkeyFromFuncPut takes a subject name, and runs the configured function to return the corresponding public key +func (d *DBTRepoServer) PubkeysFromFuncPut(subject string) (pubkeys string, err error) { + + // TODO need to implement PubkeyFromFunc. + + return pubkeys, err +} + +// PubkeyFromFuncGet takes a subject name, and runs the configured function to return the corresponding public key +func (d *DBTRepoServer) PubkeysFromFuncGet(subject string) (pubkeys string, err error) { + + // TODO need to implement PubkeyFromFunc. + + return pubkeys, err +} + +// AuthenticatedHandlerFunc is like http.HandlerFunc, but takes AuthenticatedRequest instead of http.Request +type AuthenticatedHandlerFunc func(http.ResponseWriter, *AuthenticatedRequest) + +// AuthenticatedRequest Basically an http.Request with an added Username field. The Username should never be empty. +type AuthenticatedRequest struct { + http.Request + /* + Authenticated user name. Current API implies that Username is + never empty, which means that authentication is always done + before calling the request handler. + */ + Username string +} + +// CheckPubkeyAuth Function that actually checks the Token sent by the client in the headers. +func CheckPubkeyAuth(w http.ResponseWriter, r *http.Request, pubkeyRetrievalFunc func(subject string) (pubkeys string, err error)) (username string) { + tokenString := r.Header.Get("Token") + + if tokenString == "" { + log.Info("Auth Failed: no token provided.") + w.WriteHeader(http.StatusUnauthorized) + return username + } + + // TODO sanity check username? + + //Parse the token, which includes setting up it's internals so it can be verified. + subject, token, err := agentjwt.ParsePubkeySignedToken(tokenString, pubkeyRetrievalFunc) + if err != nil { + log.Errorf("Error parsing token: %s", err) + w.WriteHeader(http.StatusBadRequest) + return username + } + + if !token.Valid { + log.Info("Auth Failed") + w.WriteHeader(http.StatusUnauthorized) + return username + } + + log.Infof("Subject %s successfully authenticated", subject) + username = subject + + return username +} + +// Wrap returns an http.HandlerFunc which wraps AuthenticatedHandlerFunc +func Wrap(wrapped AuthenticatedHandlerFunc, pubkeyRetrievalFunc func(subject string) (pubkeys string, err error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if username := CheckPubkeyAuth(w, r, pubkeyRetrievalFunc); username != "" { + ar := &AuthenticatedRequest{Request: *r, Username: username} + wrapped(w, ar) + } + } +} + +// CheckPubkeysGetFile Checks the pubkey signature in the JWT token against a public key found in a htpasswd like file and if things check out, passes things along to the provided handler. +func (d *DBTRepoServer) CheckPubkeysGetFile(wrapped http.HandlerFunc) http.HandlerFunc { + return Wrap(func(w http.ResponseWriter, ar *AuthenticatedRequest) { + ar.Header.Set("X-Authenticated-Username", ar.Username) + wrapped(w, &ar.Request) + }, d.PubkeyFromFileGet) +} + +// CheckPubkeysGetFunc Checks the pubkey signature in the JWT token against a public key produced from a function and if things check out, passes things along to the provided handler. +func (d *DBTRepoServer) CheckPubkeysGetFunc(wrapped http.HandlerFunc) http.HandlerFunc { + return Wrap(func(w http.ResponseWriter, ar *AuthenticatedRequest) { + ar.Header.Set("X-Authenticated-Username", ar.Username) + wrapped(w, &ar.Request) + }, d.PubkeysFromFuncGet) +} + +// PutHandlerPubKeyFile +func (d *DBTRepoServer) PutHandlerPubkeyFile(w http.ResponseWriter, r *http.Request) { + tokenString := r.Header.Get("Token") + + fmt.Printf("Put Token from server: %q\n", tokenString) + + if tokenString == "" { + log.Info("Put Auth Failed: no token provided.") + w.WriteHeader(http.StatusUnauthorized) + return + } + + // TODO sanity check username? + + // Parse the token, which includes setting up it's internals so it can be verified. + subject, token, err := agentjwt.ParsePubkeySignedToken(tokenString, d.PubkeyFromFilePut) + if err != nil { + log.Errorf("Error: %s", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + if !token.Valid { + log.Info("Auth Failed") + w.WriteHeader(http.StatusUnauthorized) + } + + log.Infof("Subject %s successfully authenticated", subject) + + err = d.HandlePut(r.URL.Path, r.Body, r.Header.Get("X-Checksum-Md5"), r.Header.Get("X-Checksum-Sha1"), r.Header.Get("X-Checksum-Sha256")) + if err != nil { + err = errors.Wrapf(err, "failed writing file %s", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + log.Error(err) + return + } + + w.WriteHeader(http.StatusCreated) +} + +// PutHandlerPubkeyFunc +func (d *DBTRepoServer) PutHandlerPubkeyFunc(w http.ResponseWriter, r *http.Request) { + tokenString := r.Header.Get("Token") + + if tokenString == "" { + log.Info("Put Auth Failed: no token provided.") + w.WriteHeader(http.StatusUnauthorized) + return + } + + // TODO sanity check username? + + //Parse the token, which includes setting up it's internals so it can be verified. + subject, token, err := agentjwt.ParsePubkeySignedToken(tokenString, d.PubkeysFromFuncPut) + if err != nil { + log.Errorf("Error: %s", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + if !token.Valid { + log.Info("Auth Failed") + w.WriteHeader(http.StatusUnauthorized) + } + + log.Infof("Subject %s successfully authenticated", subject) + + err = d.HandlePut(r.URL.Path, r.Body, r.Header.Get("X-Checksum-Md5"), r.Header.Get("X-Checksum-Sha1"), r.Header.Get("X-Checksum-Sha256")) + if err != nil { + err = errors.Wrapf(err, "failed writing file %s", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + log.Error(err) + return + } + + w.WriteHeader(http.StatusCreated) +} // Auth Methods // basic-htpasswd diff --git a/pkg/dbt/reposerver_auth_test.go b/pkg/dbt/reposerver_auth_test.go index 6a400b0..8f191ea 100644 --- a/pkg/dbt/reposerver_auth_test.go +++ b/pkg/dbt/reposerver_auth_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "github.com/nikogura/gomason/pkg/gomason" + "github.com/orion-labs/jwt-ssh-agent-go/pkg/agentjwt" "github.com/phayes/freeport" "github.com/stretchr/testify/assert" "html/template" @@ -30,9 +31,12 @@ func TestRepoServerAuth(t *testing.T) { cases := []struct { Name string ConfigTemplate string + AuthTypeGet string AuthFile string - Auth authinfo + AuthGet authinfo TestFiles []testfile + AuthTypePut string + AuthPut authinfo }{ { "basic", @@ -40,12 +44,17 @@ func TestRepoServerAuth(t *testing.T) { "address": "127.0.0.1", "port": {{.Port}}, "serverRoot": "{{.ServerRoot}}", - "authType": "basic-htpasswd", + "authTypeGet": "basic-htpasswd", "authGets": true, - "authOpts": { + "authOptsGet": { "idpFile": "{{.IdpFile}}" - } + }, + "authOptsPut": { + "idpFile": "{{.IdpFile}}" + }, + "authTypePut": "basic-htpasswd" }`, + "basic", "nik:$apr1$ytmDEY.X$LJt5T3fWtswK3KF5iINxT1", authinfo{ user: "nik", @@ -58,13 +67,74 @@ func TestRepoServerAuth(t *testing.T) { result: 401, auth: false, }, + { + name: "bar", + contents: "frobnitz ene woo", + result: 201, + auth: true, + }, + }, + "basic", + authinfo{ + user: "nik", + credential: "letmein", + }, + }, + { + "pubkey", + `{ + "address": "127.0.0.1", + "port": {{.Port}}, + "serverRoot": "{{.ServerRoot}}", + "authTypeGet": "ssh-agent-file", + "authGets": true, + "authOptsGet": { + "idpFile": "{{.IdpFile}}" + }, + "authOptsPut": { + "idpFile": "{{.IdpFile}}" + }, + "authTypePut": "ssh-agent-file" +}`, + "pubkey", + `{ + "getUsers": [ + { + "username": "nik", + "publickey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC6jJu0QdJfhaa8d1EH33/ee8p1JgS885g8P+s4DWbuCYdYITcuHtRq+DqgEeZGBGtocQcv2CFpzHS2K3JZzB8000tz/SOgZHT1ywqCBkaA0HObBR2cpgkC2qUmQT0WFz6/+yOF22KAqKoIRNucwTKPgQGpYeWD13ALMEvh7q1Z1HmIMKdeMCo6ziBkPiMGAbPpKqzjpUbKXaT+PkE37ouCs3YygZv6UtcTzCEsY4CIpuB45FjLKhAhA26wPVsKBSUiJCMwLhN46jDDhJ8BFSv0nUYVBT/+4nriaMeMtKO9lZ6VzHnIYzGmSWH1OWxWdRA1AixJmk2RSWlAq9yIBRJk9Tdc457j7em0hohdCGEeGyb1VuSoiEiHScnPeWsLYjc/kJIBL40vTQRyiNCT+M+7p6BlT9aTBuXsv9Njw2K60u+ekoAOE4+wlKKYNrEj09yYvdl9hVrI1bNg22JsXTYqOe4TT7Cki47cYF9QwwXPZbTBRmdDX6ftOhwBzas2mAs= dbttester@infradel.org" + } + ], + "putUsers": [ + { + "username": "nik", + "publickey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC6jJu0QdJfhaa8d1EH33/ee8p1JgS885g8P+s4DWbuCYdYITcuHtRq+DqgEeZGBGtocQcv2CFpzHS2K3JZzB8000tz/SOgZHT1ywqCBkaA0HObBR2cpgkC2qUmQT0WFz6/+yOF22KAqKoIRNucwTKPgQGpYeWD13ALMEvh7q1Z1HmIMKdeMCo6ziBkPiMGAbPpKqzjpUbKXaT+PkE37ouCs3YygZv6UtcTzCEsY4CIpuB45FjLKhAhA26wPVsKBSUiJCMwLhN46jDDhJ8BFSv0nUYVBT/+4nriaMeMtKO9lZ6VzHnIYzGmSWH1OWxWdRA1AixJmk2RSWlAq9yIBRJk9Tdc457j7em0hohdCGEeGyb1VuSoiEiHScnPeWsLYjc/kJIBL40vTQRyiNCT+M+7p6BlT9aTBuXsv9Njw2K60u+ekoAOE4+wlKKYNrEj09yYvdl9hVrI1bNg22JsXTYqOe4TT7Cki47cYF9QwwXPZbTBRmdDX6ftOhwBzas2mAs= dbttester@infradel.org" + } + ] +} +`, + authinfo{ + user: "nik", + credential: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC6jJu0QdJfhaa8d1EH33/ee8p1JgS885g8P+s4DWbuCYdYITcuHtRq+DqgEeZGBGtocQcv2CFpzHS2K3JZzB8000tz/SOgZHT1ywqCBkaA0HObBR2cpgkC2qUmQT0WFz6/+yOF22KAqKoIRNucwTKPgQGpYeWD13ALMEvh7q1Z1HmIMKdeMCo6ziBkPiMGAbPpKqzjpUbKXaT+PkE37ouCs3YygZv6UtcTzCEsY4CIpuB45FjLKhAhA26wPVsKBSUiJCMwLhN46jDDhJ8BFSv0nUYVBT/+4nriaMeMtKO9lZ6VzHnIYzGmSWH1OWxWdRA1AixJmk2RSWlAq9yIBRJk9Tdc457j7em0hohdCGEeGyb1VuSoiEiHScnPeWsLYjc/kJIBL40vTQRyiNCT+M+7p6BlT9aTBuXsv9Njw2K60u+ekoAOE4+wlKKYNrEj09yYvdl9hVrI1bNg22JsXTYqOe4TT7Cki47cYF9QwwXPZbTBRmdDX6ftOhwBzas2mAs= dbttester@infradel.org", + }, + []testfile{ { name: "foo", contents: "frobnitz ene woo", + result: 401, + auth: false, + }, + { + name: "bar", + contents: "frobnitz ene woo", result: 201, auth: true, }, }, + "pubkey", + authinfo{ + user: "nik", + credential: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC6jJu0QdJfhaa8d1EH33/ee8p1JgS885g8P+s4DWbuCYdYITcuHtRq+DqgEeZGBGtocQcv2CFpzHS2K3JZzB8000tz/SOgZHT1ywqCBkaA0HObBR2cpgkC2qUmQT0WFz6/+yOF22KAqKoIRNucwTKPgQGpYeWD13ALMEvh7q1Z1HmIMKdeMCo6ziBkPiMGAbPpKqzjpUbKXaT+PkE37ouCs3YygZv6UtcTzCEsY4CIpuB45FjLKhAhA26wPVsKBSUiJCMwLhN46jDDhJ8BFSv0nUYVBT/+4nriaMeMtKO9lZ6VzHnIYzGmSWH1OWxWdRA1AixJmk2RSWlAq9yIBRJk9Tdc457j7em0hohdCGEeGyb1VuSoiEiHScnPeWsLYjc/kJIBL40vTQRyiNCT+M+7p6BlT9aTBuXsv9Njw2K60u+ekoAOE4+wlKKYNrEj09yYvdl9hVrI1bNg22JsXTYqOe4TT7Cki47cYF9QwwXPZbTBRmdDX6ftOhwBzas2mAs= dbttester@infradel.org", + }, }, } @@ -94,9 +164,11 @@ func TestRepoServerAuth(t *testing.T) { err = ioutil.WriteFile(idpFile, []byte(tc.AuthFile), 0644) if err != nil { - t.Fatalf("Failed creating auth file %s: %s", idpFile, err) + t.Fatalf("Failed creating get auth file %s: %s", idpFile, err) } + // TODO start ssh agent if necessary? + // write config file tmplData := struct { Port int @@ -117,7 +189,7 @@ func TestRepoServerAuth(t *testing.T) { err = tmpl.Execute(buf, tmplData) if err != nil { - t.Fatalf("Failed to execute template for %s", tc.Name) + t.Fatalf("Failed to execute template for %s: %s", tc.Name, err) } err = ioutil.WriteFile(configFile, buf.Bytes(), 0644) @@ -140,7 +212,8 @@ func TestRepoServerAuth(t *testing.T) { fmt.Printf("Sleeping for 1 second for the test artifact server to start up.") time.Sleep(time.Second * 1) - // write some files + // PUT test files. This is a basic HTTP request, not doing anything fancy through the DBT client. + // DBT is only a reader. How you write the files is up to you, but the auth mechanism is the same regardless. client := &http.Client{} for _, file := range tc.TestFiles { @@ -148,15 +221,36 @@ func TestRepoServerAuth(t *testing.T) { req, err := http.NewRequest(http.MethodPut, fileUrl, bytes.NewReader([]byte(file.contents))) if err != nil { - t.Errorf("Failed creatign request for %s: %s", file.name, err) + t.Errorf("Failed creating request for %s: %s", file.name, err) } + fmt.Printf("Writing %s to server\n", fileUrl) + if file.auth { - req.SetBasicAuth(tc.Auth.user, tc.Auth.credential) + switch tc.AuthTypePut { + case "basic": + fmt.Printf("Basic Authed Request.\n") + req.SetBasicAuth(tc.AuthPut.user, tc.AuthPut.credential) + + case "pubkey": + fmt.Printf("Pubkey Authed Request.\n") + // use username and pubkey to set Token header + token, err := agentjwt.SignedJwtToken(tc.AuthPut.user, tc.AuthPut.credential) + if err != nil { + t.Errorf("failed to sign JWT token: %s", err) + } + + fmt.Printf("Token in client: %q\n", token) + + if token != "" { + fmt.Printf("Adding token header to request.\n") + req.Header.Add("Token", token) + } + } + } else { + fmt.Printf("Unauthenticated Request.\n") } - fmt.Printf("Writing %s to server\n", fileUrl) - resp, err := client.Do(req) if err != nil { t.Errorf("failed writing file %s: %s", file.name, err) @@ -166,7 +260,7 @@ func TestRepoServerAuth(t *testing.T) { assert.Equal(t, file.result, resp.StatusCode, "File put request response code did not meet expectations.") - // verify file was written + // GET Test files // don't bother with unauthenticated files. We don't allow that. if file.auth { fmt.Printf("Verifying %s exists on server\n", fileUrl) @@ -185,7 +279,30 @@ func TestRepoServerAuth(t *testing.T) { req.Header.Set("X-Checksum-Sha1", sha1sum) req.Header.Set("X-Checksum-Sha256", sha256sum) - req.SetBasicAuth(tc.Auth.user, tc.Auth.credential) + if file.auth { + switch tc.AuthTypePut { + case "basic": + fmt.Printf("Basic Authed Request.\n") + req.SetBasicAuth(tc.AuthGet.user, tc.AuthGet.credential) + + case "pubkey": + fmt.Printf("Pubkey Authed Request.\n") + // use username and pubkey to set Token header + token, err := agentjwt.SignedJwtToken(tc.AuthGet.user, tc.AuthGet.credential) + if err != nil { + t.Errorf("failed to sign JWT token: %s", err) + } + + fmt.Printf("Token in client: %q\n", token) + + if token != "" { + fmt.Printf("Adding token header to request.\n") + req.Header.Add("Token", token) + } + } + } else { + fmt.Printf("Unauthenticated Request.\n") + } resp, err := client.Do(req) if err != nil { @@ -214,7 +331,6 @@ func TestRepoServerAuth(t *testing.T) { if _, err := os.Stat(tmpDir); !os.IsNotExist(err) { _ = os.Remove(tmpDir) } - }) } } diff --git a/pkg/dbt/util.go b/pkg/dbt/util.go index 7d1978b..6a9f892 100644 --- a/pkg/dbt/util.go +++ b/pkg/dbt/util.go @@ -20,6 +20,7 @@ import ( "encoding/base64" "encoding/hex" "fmt" + "github.com/orion-labs/jwt-ssh-agent-go/pkg/agentjwt" "github.com/pkg/errors" "io" "io/ioutil" @@ -262,8 +263,10 @@ func GetFunc(shellCommand string) (result string, err error) { return result, err } -// AuthHeaders Convenience function to add auth headers - basic or token for non-s3 requests. +// AuthHeaders Convenience function to add auth headers - basic or token for non-s3 requests. Depending on how client is configured, could result in both Basic Auth and Token headers. Reposerver will, however only pay attention to one or the other. func (dbt *DBT) AuthHeaders(r *http.Request) (err error) { + // Basic Auth + // start with values hardcoded in the config file username := dbt.Config.Username password := dbt.Config.Password @@ -289,5 +292,43 @@ func (dbt *DBT) AuthHeaders(r *http.Request) (err error) { r.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(username+":"+password))) } + // Pubkey JWT Auth + // Start with username and pubkey hardcoded in the config + pubkey := dbt.Config.Pubkey + + // read pubkey from file + if dbt.Config.PubkeyPath != "" { + b, err := ioutil.ReadFile(dbt.Config.PubkeyPath) + if err != nil { + err = errors.Wrapf(err, "failed to read public key from file %s", dbt.Config.PubkeyPath) + return err + } + + pubkey = string(b) + + } + + // PubkeyFunc takes precedence over files and hardcoding + if dbt.Config.PubkeyFunc != "" { + pubkey, err = GetFunc(dbt.Config.PubkeyFunc) + if err != nil { + err = errors.Wrapf(err, "failed to get public key from shell function %q", dbt.Config.PubkeyFunc) + return err + } + } + + // Don't try to sign a token if we don't actually have a public key + if pubkey != "" { + // use username and pubkey to set Token header + token, err := agentjwt.SignedJwtToken(username, pubkey) + if err != nil { + err = errors.Wrapf(err, "failed to sign JWT token") + } + + if token != "" { + r.Header.Add("Token", token) + } + } + return err }