From a35ec27b40ce3ca0797cdcd8e0a79b2b8896af47 Mon Sep 17 00:00:00 2001 From: Tom Clegg Date: Mon, 22 Jun 2020 16:58:14 -0400 Subject: [PATCH] 15348: Add Go-based PAM module. Arvados-DCO-1.1-Signed-off-by: Tom Clegg --- lib/pam/.gitignore | 2 + lib/pam/docker_test.go | 143 +++++++++++++++++++++++++++++ lib/pam/pam_arvados.go | 148 ++++++++++++++++++++++++++++++ lib/pam/pam_c.go | 24 +++++ lib/pam/testclient.go | 83 +++++++++++++++++ sdk/go/arvados/link.go | 17 ++-- sdk/go/arvados/virtual_machine.go | 25 +++++ 7 files changed, 434 insertions(+), 8 deletions(-) create mode 100644 lib/pam/.gitignore create mode 100644 lib/pam/docker_test.go create mode 100644 lib/pam/pam_arvados.go create mode 100644 lib/pam/pam_c.go create mode 100644 lib/pam/testclient.go create mode 100644 sdk/go/arvados/virtual_machine.go diff --git a/lib/pam/.gitignore b/lib/pam/.gitignore new file mode 100644 index 0000000000..8d44d630d7 --- /dev/null +++ b/lib/pam/.gitignore @@ -0,0 +1,2 @@ +pam_arvados.h +pam_arvados.so diff --git a/lib/pam/docker_test.go b/lib/pam/docker_test.go new file mode 100644 index 0000000000..455d264411 --- /dev/null +++ b/lib/pam/docker_test.go @@ -0,0 +1,143 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "crypto/tls" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "strings" + "testing" + + "git.arvados.org/arvados.git/sdk/go/arvadostest" + "gopkg.in/check.v1" +) + +type DockerSuite struct { + tmpdir string + hostip string + proxyln net.Listener + proxysrv *http.Server +} + +var _ = check.Suite(&DockerSuite{}) + +func Test(t *testing.T) { check.TestingT(t) } + +func (s *DockerSuite) SetUpSuite(c *check.C) { + if testing.Short() { + c.Skip("skipping docker tests in short mode") + } else if _, err := exec.Command("docker", "info").CombinedOutput(); err != nil { + c.Skip("skipping docker tests because docker is not available") + } + + s.tmpdir = c.MkDir() + + // The integration-testing controller listens on the loopback + // interface, so it won't be reachable directly from the + // docker container -- so here we run a proxy on 0.0.0.0 for + // the duration of the test. + hostips, err := exec.Command("hostname", "-I").Output() + c.Assert(err, check.IsNil) + s.hostip = strings.Split(strings.Trim(string(hostips), "\n"), " ")[0] + ln, err := net.Listen("tcp", s.hostip+":0") + c.Assert(err, check.IsNil) + s.proxyln = ln + proxy := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "https", Host: os.Getenv("ARVADOS_API_HOST")}) + proxy.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + s.proxysrv = &http.Server{Handler: proxy} + go s.proxysrv.ServeTLS(ln, "../../services/api/tmp/self-signed.pem", "../../services/api/tmp/self-signed.key") + proxyhost := ln.Addr().String() + + // Build a pam module to install & configure in the docker + // container. + cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", s.tmpdir+"/pam_arvados.so") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + c.Assert(err, check.IsNil) + + // Write a PAM config file that uses our proxy as + // ARVADOS_API_HOST. + confdata := fmt.Sprintf(`Name: Arvados authentication +Default: yes +Priority: 256 +Auth-Type: Primary +Auth: + [success=end default=ignore] /usr/lib/security/pam_arvados.so %s testvm2.shell insecure +Auth-Initial: + [success=end default=ignore] /usr/lib/security/pam_arvados.so %s testvm2.shell insecure +`, proxyhost, proxyhost) + err = ioutil.WriteFile(s.tmpdir+"/conffile", []byte(confdata), 0755) + c.Assert(err, check.IsNil) + + // Build the testclient program that will (from inside the + // docker container) configure the system to use the above PAM + // config, and then try authentication. + cmd = exec.Command("go", "build", "-o", s.tmpdir+"/testclient", "./testclient.go") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + c.Assert(err, check.IsNil) +} + +func (s *DockerSuite) TearDownSuite(c *check.C) { + s.proxysrv.Close() + s.proxyln.Close() +} + +func (s *DockerSuite) runTestClient(c *check.C, args ...string) (stdout, stderr *bytes.Buffer, err error) { + cmd := exec.Command("docker", append([]string{ + "run", "--rm", + "--add-host", "zzzzz.arvadosapi.com:" + s.hostip, + "-v", s.tmpdir + "/pam_arvados.so:/usr/lib/security/pam_arvados.so:ro", + "-v", s.tmpdir + "/conffile:/usr/share/pam-configs/arvados:ro", + "-v", s.tmpdir + "/testclient:/testclient:ro", + "debian:buster", + "/testclient"}, args...)...) + stdout = &bytes.Buffer{} + stderr = &bytes.Buffer{} + cmd.Stdout = stdout + cmd.Stderr = stderr + err = cmd.Run() + return +} + +func (s *DockerSuite) TestSuccess(c *check.C) { + stdout, stderr, err := s.runTestClient(c, "try", "active", arvadostest.ActiveTokenV2) + c.Check(err, check.IsNil) + c.Check(stdout.String(), check.Equals, "") + c.Check(stderr.String(), check.Matches, `(?ms).*authentication succeeded.*`) +} + +func (s *DockerSuite) TestFailure(c *check.C) { + for _, trial := range []struct { + label string + username string + token string + }{ + {"bad token", "active", arvadostest.ActiveTokenV2 + "badtoken"}, + {"empty token", "active", ""}, + {"empty username", "", arvadostest.ActiveTokenV2}, + {"wrong username", "wrongusername", arvadostest.ActiveTokenV2}, + } { + c.Logf("trial: %s", trial.label) + stdout, stderr, err := s.runTestClient(c, "try", trial.username, trial.token) + c.Check(err, check.NotNil) + c.Check(stdout.String(), check.Equals, "") + c.Check(stderr.String(), check.Matches, `(?ms).*authentication failed.*`) + } +} diff --git a/lib/pam/pam_arvados.go b/lib/pam/pam_arvados.go new file mode 100644 index 0000000000..ddca355b87 --- /dev/null +++ b/lib/pam/pam_arvados.go @@ -0,0 +1,148 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "io/ioutil" + "log/syslog" + + "context" + "errors" + "fmt" + "runtime" + "syscall" + "time" + + "git.arvados.org/arvados.git/sdk/go/arvados" + "github.com/sirupsen/logrus" + lSyslog "github.com/sirupsen/logrus/hooks/syslog" + "golang.org/x/sys/unix" +) + +/* +#cgo LDFLAGS: -lpam -fPIC +#include +char *stringindex(char** a, int i); +const char *get_user(pam_handle_t *pamh); +const char *get_authtoken(pam_handle_t *pamh); +*/ +import "C" + +func main() {} + +func init() { + if err := unix.Prctl(syscall.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil { + newLogger(false).WithError(err).Warn("unable to disable ptrace") + } +} + +//export pam_sm_authenticate +func pam_sm_authenticate(pamh *C.pam_handle_t, flags, cArgc C.int, cArgv **C.char) C.int { + runtime.GOMAXPROCS(1) + logger := newLogger(flags&C.PAM_SILENT == 0) + cUsername := C.get_user(pamh) + if cUsername == nil { + return C.PAM_USER_UNKNOWN + } + + cToken := C.get_authtoken(pamh) + if cToken == nil { + return C.PAM_AUTH_ERR + } + + argv := make([]string, cArgc) + for i := 0; i < int(cArgc); i++ { + argv[i] = C.GoString(C.stringindex(cArgv, C.int(i))) + } + + err := authenticate(logger, C.GoString(cUsername), C.GoString(cToken), argv) + if err != nil { + logger.WithError(err).Error("authentication failed") + return C.PAM_AUTH_ERR + } + return C.PAM_SUCCESS +} + +func authenticate(logger logrus.FieldLogger, username, token string, argv []string) error { + hostname := "" + apiHost := "" + insecure := false + for idx, arg := range argv { + if idx == 0 { + apiHost = arg + } else if idx == 1 { + hostname = arg + } else if arg == "insecure" { + insecure = true + } else { + logger.Warnf("unkown option: %s\n", arg) + } + } + logger.Debugf("username=%q arvados_api_host=%q hostname=%q insecure=%t", username, apiHost, hostname, insecure) + if apiHost == "" || hostname == "" { + logger.Warnf("cannot authenticate: config error: arvados_api_host and hostname must be non-empty") + return errors.New("config error") + } + arv := &arvados.Client{ + Scheme: "https", + APIHost: apiHost, + AuthToken: token, + Insecure: insecure, + } + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute)) + defer cancel() + var vms arvados.VirtualMachineList + err := arv.RequestAndDecodeContext(ctx, &vms, "GET", "arvados/v1/virtual_machines", nil, arvados.ListOptions{ + Limit: 2, + Filters: []arvados.Filter{ + {"hostname", "=", hostname}, + }, + }) + if err != nil { + return err + } + if len(vms.Items) == 0 { + return fmt.Errorf("no results for hostname %q", hostname) + } else if len(vms.Items) > 1 { + return fmt.Errorf("multiple results for hostname %q", hostname) + } else if vms.Items[0].Hostname != hostname { + return fmt.Errorf("looked up hostname %q but controller returned record with hostname %q", hostname, vms.Items[0].Hostname) + } + var user arvados.User + err = arv.RequestAndDecodeContext(ctx, &user, "GET", "arvados/v1/users/current", nil, nil) + if err != nil { + return err + } + var links arvados.LinkList + err = arv.RequestAndDecodeContext(ctx, &links, "GET", "arvados/v1/links", nil, arvados.ListOptions{ + Limit: 10000, + Filters: []arvados.Filter{ + {"link_class", "=", "permission"}, + {"name", "=", "can_login"}, + {"tail_uuid", "=", user.UUID}, + {"head_uuid", "=", vms.Items[0].UUID}, + {"properties.username", "=", username}, + }, + }) + if err != nil { + return err + } + if len(links.Items) < 1 || links.Items[0].Properties["username"] != username { + return errors.New("permission denied") + } + logger.Debugf("permission granted based on link with UUID %s", links.Items[0].UUID) + return nil +} + +func newLogger(stderr bool) *logrus.Logger { + logger := logrus.New() + if !stderr { + logger.Out = ioutil.Discard + } + if hook, err := lSyslog.NewSyslogHook("udp", "localhost:514", syslog.LOG_AUTH|syslog.LOG_INFO, "pam_arvados"); err != nil { + logger.Hooks.Add(hook) + } + return logger +} diff --git a/lib/pam/pam_c.go b/lib/pam/pam_c.go new file mode 100644 index 0000000000..4bf975b22c --- /dev/null +++ b/lib/pam/pam_c.go @@ -0,0 +1,24 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +/* +#cgo LDFLAGS: -lpam -fPIC +#include +char *stringindex(char** a, int i) { return a[i]; } +const char *get_user(pam_handle_t *pamh) { + const char *user; + if (pam_get_item(pamh, PAM_USER, (const void**)&user) != PAM_SUCCESS) + return NULL; + return user; +} +const char *get_authtoken(pam_handle_t *pamh) { + const char *token; + if (pam_get_authtok(pamh, PAM_AUTHTOK, &token, NULL) != PAM_SUCCESS) + return NULL; + return token; +} +*/ +import "C" diff --git a/lib/pam/testclient.go b/lib/pam/testclient.go new file mode 100644 index 0000000000..3e92cac444 --- /dev/null +++ b/lib/pam/testclient.go @@ -0,0 +1,83 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +// +build never + +// This file is compiled by docker_test.go to build a test client. +// It's not part of the pam module itself. + +package main + +import ( + "fmt" + "os" + "os/exec" + + "github.com/msteinert/pam" + "github.com/sirupsen/logrus" +) + +func main() { + if len(os.Args) != 4 || os.Args[1] != "try" { + logrus.Print("usage: testclient try 'username' 'password'") + os.Exit(1) + } + username := os.Args[2] + password := os.Args[3] + + // Configure PAM to use arvados token auth by default. + cmd := exec.Command("pam-auth-update", "--force", "arvados", "--remove", "unix") + cmd.Env = append([]string{"DEBIAN_FRONTEND=noninteractive"}, os.Environ()...) + cmd.Stdin = nil + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + logrus.WithError(err).Error("pam-auth-update failed") + os.Exit(1) + } + + // Check that pam-auth-update actually added arvados config. + cmd = exec.Command("grep", "-Hn", "arvados", "/etc/pam.d/common-auth") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + panic(err) + } + + logrus.Debugf("starting pam: username=%q password=%q", username, password) + + sentPassword := false + errorMessage := "" + tx, err := pam.StartFunc("default", username, func(style pam.Style, message string) (string, error) { + logrus.Debugf("pam conversation: style=%v message=%q", style, message) + switch style { + case pam.ErrorMsg: + logrus.WithField("Message", message).Info("pam.ErrorMsg") + errorMessage = message + return "", nil + case pam.TextInfo: + logrus.WithField("Message", message).Info("pam.TextInfo") + errorMessage = message + return "", nil + case pam.PromptEchoOn, pam.PromptEchoOff: + sentPassword = true + return password, nil + default: + return "", fmt.Errorf("unrecognized message style %d", style) + } + }) + if err != nil { + logrus.WithError(err).Print("StartFunc failed") + os.Exit(1) + } + err = tx.Authenticate(pam.DisallowNullAuthtok) + if err != nil { + err = fmt.Errorf("PAM: %s (message = %q)", err, errorMessage) + logrus.WithError(err).Print("authentication failed") + os.Exit(1) + } + logrus.Print("authentication succeeded") +} diff --git a/sdk/go/arvados/link.go b/sdk/go/arvados/link.go index fbd699f306..fdddfc537d 100644 --- a/sdk/go/arvados/link.go +++ b/sdk/go/arvados/link.go @@ -6,14 +6,15 @@ package arvados // Link is an arvados#link record type Link struct { - UUID string `json:"uuid,omiempty"` - OwnerUUID string `json:"owner_uuid"` - Name string `json:"name"` - LinkClass string `json:"link_class"` - HeadUUID string `json:"head_uuid"` - HeadKind string `json:"head_kind"` - TailUUID string `json:"tail_uuid"` - TailKind string `json:"tail_kind"` + UUID string `json:"uuid,omiempty"` + OwnerUUID string `json:"owner_uuid"` + Name string `json:"name"` + LinkClass string `json:"link_class"` + HeadUUID string `json:"head_uuid"` + HeadKind string `json:"head_kind"` + TailUUID string `json:"tail_uuid"` + TailKind string `json:"tail_kind"` + Properties map[string]interface{} `json:"properties"` } // UserList is an arvados#userList resource. diff --git a/sdk/go/arvados/virtual_machine.go b/sdk/go/arvados/virtual_machine.go new file mode 100644 index 0000000000..1506ede209 --- /dev/null +++ b/sdk/go/arvados/virtual_machine.go @@ -0,0 +1,25 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package arvados + +import "time" + +// VirtualMachine is an arvados#virtualMachine resource. +type VirtualMachine struct { + UUID string `json:"uuid"` + OwnerUUID string `json:"owner_uuid"` + Hostname string `json:"hostname"` + CreatedAt *time.Time `json:"created_at"` + ModifiedAt *time.Time `json:"modified_at"` + ModifiedByUserUUID string `json:"modified_by_user_uuid"` +} + +// VirtualMachineList is an arvados#virtualMachineList resource. +type VirtualMachineList struct { + Items []VirtualMachine `json:"items"` + ItemsAvailable int `json:"items_available"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} -- 2.39.5