16212: Support username/password authentication via PAM.
authorTom Clegg <tom@tomclegg.ca>
Thu, 12 Mar 2020 22:14:09 +0000 (18:14 -0400)
committerTom Clegg <tom@tomclegg.ca>
Thu, 12 Mar 2020 22:14:09 +0000 (18:14 -0400)
Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@tomclegg.ca>

16 files changed:
build/run-tests.sh
go.mod
go.sum
lib/config/config.default.yml
lib/config/export.go
lib/config/generated_config.go
lib/controller/localdb/conn.go
lib/controller/localdb/login.go
lib/controller/localdb/login_google.go [new file with mode: 0644]
lib/controller/localdb/login_google_test.go [moved from lib/controller/localdb/login_test.go with 94% similarity]
lib/controller/localdb/login_pam.go [new file with mode: 0644]
lib/controller/localdb/login_pam_test.go [new file with mode: 0644]
sdk/go/arvados/api.go
sdk/go/arvados/client.go
sdk/go/arvados/config.go
sdk/go/arvados/login.go

index 7328fad45ee76025f6b8d503d379935ba000cd6a..106793f7a62334b27c85102889831237b25f49eb 100755 (executable)
@@ -262,6 +262,9 @@ sanity_checks() {
     echo -n 'libpq libpq-fe.h: '
     find /usr/include -path '*/postgresql/libpq-fe.h' | egrep --max-count=1 . \
         || fatal "No libpq libpq-fe.h. Try: apt-get install libpq-dev"
+    echo -n 'libpam pam_appl.h: '
+    find /usr/include -path '*/security/pam_appl.h' | egrep --max-count=1 . \
+        || fatal "No libpam pam_appl.h. Try: apt-get install libpam-dev"
     echo -n 'postgresql: '
     psql --version || fatal "No postgresql. Try: apt-get install postgresql postgresql-client-common"
     echo -n 'phantomjs: '
diff --git a/go.mod b/go.mod
index 2cc5e89eb1fe68c88335e2ba2e2906e1bb2d9c33..4491b359813c00ca2d39af34f4d6587e49290699 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -36,6 +36,7 @@ require (
        github.com/lib/pq v1.3.0
        github.com/marstr/guid v1.1.1-0.20170427235115-8bdf7d1a087c // indirect
        github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747 // indirect
+       github.com/msteinert/pam v0.0.0-20190215180659-f29b9f28d6f9
        github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
        github.com/opencontainers/image-spec v1.0.1-0.20171125024018-577479e4dc27 // indirect
        github.com/pelletier/go-buffruneio v0.2.0 // indirect
@@ -52,7 +53,7 @@ require (
        golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
        golang.org/x/net v0.0.0-20190620200207-3b0461eec859
        golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
-       golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd // indirect
+       golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd
        google.golang.org/api v0.13.0
        gopkg.in/check.v1 v1.0.0-20161208181325-20d25e280405
        gopkg.in/square/go-jose.v2 v2.3.1
diff --git a/go.sum b/go.sum
index c3904fe84b70b1e79323b18601d989d3527b289d..18cf89b0e17e6130fa2e18cb2b4b067de54d506d 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -123,6 +123,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/msteinert/pam v0.0.0-20190215180659-f29b9f28d6f9 h1:ZivaaKmjs9q90zi6I4gTLW6tbVGtlBjellr3hMYaly0=
+github.com/msteinert/pam v0.0.0-20190215180659-f29b9f28d6f9/go.mod h1:np1wUFZ6tyoke22qDJZY40URn9Ae51gX7ljIWXN5TJs=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ=
 github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
index 411296cbea60f4c39122fb72ee8db94d9f31cacf..a4616d70b906e1c1aba03f99add3738e76166cf3 100644 (file)
@@ -541,6 +541,29 @@ Clusters:
       # work. If false, only the primary email address will be used.
       GoogleAlternateEmailAddresses: true
 
+      # (Experimental) Use PAM to authenticate logins, using the
+      # specified PAM service name.
+      #
+      # Cannot be used in combination with OAuth2 (ProviderAppID) or
+      # Google (GoogleClientID). Cannot be used on a cluster acting as
+      # a LoginCluster.
+      PAM: false
+      PAMService: arvados
+
+      # Domain name (e.g., "example.com") to use to construct the
+      # user's email address if PAM authentication returns a username
+      # with no "@". If empty, use the PAM username as the user's
+      # email address, whether or not it contains "@".
+      #
+      # Note that the email address is used as the primary key for
+      # user records when logging in. Therefore, if you change
+      # PAMDefaultEmailDomain after the initial installation, you
+      # should also update existing user records to reflect the new
+      # domain. Otherwise, next time those users log in, they will be
+      # given new accounts instead of accessing their existing
+      # accounts.
+      PAMDefaultEmailDomain: ""
+
       # The cluster ID to delegate the user database.  When set,
       # logins on this cluster will be redirected to the login cluster
       # (login cluster must appear in RemoteClusters with Proxy: true)
index 5973a16a132a399e2988d6b8939bab8f03911308..ded03fc3030c8811a6d12210a6c1f9b57253dfaf 100644 (file)
@@ -134,6 +134,9 @@ var whitelist = map[string]bool{
        "Login.GoogleClientID":                         false,
        "Login.GoogleClientSecret":                     false,
        "Login.GoogleAlternateEmailAddresses":          false,
+       "Login.PAM":                                    true,
+       "Login.PAMService":                             false,
+       "Login.PAMDefaultEmailDomain":                  false,
        "Login.ProviderAppID":                          false,
        "Login.ProviderAppSecret":                      false,
        "Login.LoginCluster":                           true,
index f40093a96c5fa0a7bdeae7dc7b11316a247ed561..368569103e010071e3f573cc9f23a942e2d94d30 100644 (file)
@@ -547,6 +547,28 @@ Clusters:
       # work. If false, only the primary email address will be used.
       GoogleAlternateEmailAddresses: true
 
+      # Use PAM to authenticate logins, using the specified PAM
+      # service name.
+      #
+      # Cannot be used in combination with OAuth2 (ProviderAppID) or
+      # Google (GoogleClientID).
+      PAM: false
+      PAMService: arvados
+
+      # Domain name (e.g., "example.com") to use to construct the
+      # user's email address if PAM authentication returns a username
+      # with no "@". If empty, use the PAM username as the user's
+      # email address, whether or not it contains "@".
+      #
+      # Note that the email address is used as the primary key for
+      # user records when logging in. Therefore, if you change
+      # PAMDefaultEmailDomain after the initial installation, you
+      # should also update existing user records to reflect the new
+      # domain. Otherwise, next time those users log in, they will be
+      # given new accounts instead of accessing their existing
+      # accounts.
+      PAMDefaultEmailDomain: ""
+
       # The cluster ID to delegate the user database.  When set,
       # logins on this cluster will be redirected to the login cluster
       # (login cluster must appear in RemoteClusters with Proxy: true)
index ac092382d42a20c6ec4caad4033ed4a5679d7632..909b6e1ff33a8489bae3a13a4839f902cb2d5a44 100644 (file)
@@ -6,7 +6,6 @@ package localdb
 
 import (
        "context"
-       "errors"
 
        "git.arvados.org/arvados.git/lib/controller/railsproxy"
        "git.arvados.org/arvados.git/lib/controller/rpc"
@@ -18,35 +17,22 @@ type railsProxy = rpc.Conn
 type Conn struct {
        cluster     *arvados.Cluster
        *railsProxy // handles API methods that aren't defined on Conn itself
-
-       googleLoginController
+       loginController
 }
 
 func NewConn(cluster *arvados.Cluster) *Conn {
+       railsProxy := railsproxy.NewConn(cluster)
        return &Conn{
-               cluster:    cluster,
-               railsProxy: railsproxy.NewConn(cluster),
+               cluster:         cluster,
+               railsProxy:      railsProxy,
+               loginController: chooseLoginController(cluster, railsProxy),
        }
 }
 
 func (conn *Conn) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
-       if conn.cluster.Login.ProviderAppID != "" {
-               // Proxy to RailsAPI, which hands off to sso-provider.
-               return conn.railsProxy.Logout(ctx, opts)
-       } else {
-               return conn.googleLoginController.Logout(ctx, conn.cluster, conn.railsProxy, opts)
-       }
+       return conn.loginController.Logout(ctx, opts)
 }
 
 func (conn *Conn) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
-       wantGoogle := conn.cluster.Login.GoogleClientID != ""
-       wantSSO := conn.cluster.Login.ProviderAppID != ""
-       if wantGoogle == wantSSO {
-               return arvados.LoginResponse{}, errors.New("configuration problem: exactly one of Login.GoogleClientID and Login.ProviderAppID must be configured")
-       } else if wantGoogle {
-               return conn.googleLoginController.Login(ctx, conn.cluster, conn.railsProxy, opts)
-       } else {
-               // Proxy to RailsAPI, which hands off to sso-provider.
-               return conn.railsProxy.Login(ctx, opts)
-       }
+       return conn.loginController.Login(ctx, opts)
 }
index 2e50b84f435856dc282be51b4fbe8b5db548431b..af9a0348274c672bed8edcf673a13c5b9486b7a0 100644 (file)
@@ -5,54 +5,45 @@
 package localdb
 
 import (
-       "bytes"
        "context"
-       "crypto/hmac"
-       "crypto/sha256"
-       "encoding/base64"
        "errors"
-       "fmt"
-       "net/url"
-       "strings"
-       "sync"
-       "text/template"
-       "time"
 
-       "git.arvados.org/arvados.git/lib/controller/rpc"
        "git.arvados.org/arvados.git/sdk/go/arvados"
-       "git.arvados.org/arvados.git/sdk/go/auth"
-       "git.arvados.org/arvados.git/sdk/go/ctxlog"
-       "github.com/coreos/go-oidc"
-       "golang.org/x/oauth2"
-       "google.golang.org/api/option"
-       "google.golang.org/api/people/v1"
 )
 
-type googleLoginController struct {
-       issuer            string // override OIDC issuer URL (normally https://accounts.google.com) for testing
-       peopleAPIBasePath string // override Google People API base URL (normally set by google pkg to https://people.googleapis.com/)
-       provider          *oidc.Provider
-       mu                sync.Mutex
+type loginController interface {
+       Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error)
+       Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error)
 }
 
-func (ctrl *googleLoginController) getProvider() (*oidc.Provider, error) {
-       ctrl.mu.Lock()
-       defer ctrl.mu.Unlock()
-       if ctrl.provider == nil {
-               issuer := ctrl.issuer
-               if issuer == "" {
-                       issuer = "https://accounts.google.com"
+func chooseLoginController(cluster *arvados.Cluster, railsProxy *railsProxy) loginController {
+       wantGoogle := cluster.Login.GoogleClientID != ""
+       wantSSO := cluster.Login.ProviderAppID != ""
+       wantPAM := cluster.Login.PAM
+       switch {
+       case wantGoogle && !wantSSO && !wantPAM:
+               return &googleLoginController{Cluster: cluster, RailsProxy: railsProxy}
+       case !wantGoogle && wantSSO && !wantPAM:
+               return railsProxy
+       case !wantGoogle && !wantSSO && wantPAM:
+               return &pamLoginController{Cluster: cluster, RailsProxy: railsProxy}
+       default:
+               return errorLoginController{
+                       error: errors.New("configuration problem: exactly one of Login.GoogleClientID, Login.ProviderAppID, or Login.PAM must be configured"),
                }
-               provider, err := oidc.NewProvider(context.Background(), issuer)
-               if err != nil {
-                       return nil, err
-               }
-               ctrl.provider = provider
        }
-       return ctrl.provider, nil
 }
 
-func (ctrl *googleLoginController) Logout(ctx context.Context, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+type errorLoginController struct{ error }
+
+func (ctrl errorLoginController) Login(context.Context, arvados.LoginOptions) (arvados.LoginResponse, error) {
+       return arvados.LoginResponse{}, ctrl.error
+}
+func (ctrl errorLoginController) Logout(context.Context, arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+       return arvados.LogoutResponse{}, ctrl.error
+}
+
+func noopLogout(cluster *arvados.Cluster, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
        target := opts.ReturnTo
        if target == "" {
                if cluster.Services.Workbench2.ExternalURL.Host != "" {
@@ -63,228 +54,3 @@ func (ctrl *googleLoginController) Logout(ctx context.Context, cluster *arvados.
        }
        return arvados.LogoutResponse{RedirectLocation: target}, nil
 }
-
-func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
-       provider, err := ctrl.getProvider()
-       if err != nil {
-               return ctrl.loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
-       }
-       redirURL, err := (*url.URL)(&cluster.Services.Controller.ExternalURL).Parse("/login")
-       if err != nil {
-               return ctrl.loginError(fmt.Errorf("error making redirect URL: %s", err))
-       }
-       conf := &oauth2.Config{
-               ClientID:     cluster.Login.GoogleClientID,
-               ClientSecret: cluster.Login.GoogleClientSecret,
-               Endpoint:     provider.Endpoint(),
-               Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
-               RedirectURL:  redirURL.String(),
-       }
-       verifier := provider.Verifier(&oidc.Config{
-               ClientID: conf.ClientID,
-       })
-       if opts.State == "" {
-               // Initiate Google sign-in.
-               if opts.ReturnTo == "" {
-                       return ctrl.loginError(errors.New("missing return_to parameter"))
-               }
-               me := url.URL(cluster.Services.Controller.ExternalURL)
-               callback, err := me.Parse("/" + arvados.EndpointLogin.Path)
-               if err != nil {
-                       return ctrl.loginError(err)
-               }
-               conf.RedirectURL = callback.String()
-               state := ctrl.newOAuth2State([]byte(cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
-               return arvados.LoginResponse{
-                       RedirectLocation: conf.AuthCodeURL(state.String(),
-                               // prompt=select_account tells Google
-                               // to show the "choose which Google
-                               // account" page, even if the client
-                               // is currently logged in to exactly
-                               // one Google account.
-                               oauth2.SetAuthURLParam("prompt", "select_account")),
-               }, nil
-       } else {
-               // Callback after Google sign-in.
-               state := ctrl.parseOAuth2State(opts.State)
-               if !state.verify([]byte(cluster.SystemRootToken)) {
-                       return ctrl.loginError(errors.New("invalid OAuth2 state"))
-               }
-               oauth2Token, err := conf.Exchange(ctx, opts.Code)
-               if err != nil {
-                       return ctrl.loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
-               }
-               rawIDToken, ok := oauth2Token.Extra("id_token").(string)
-               if !ok {
-                       return ctrl.loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
-               }
-               idToken, err := verifier.Verify(ctx, rawIDToken)
-               if err != nil {
-                       return ctrl.loginError(fmt.Errorf("error verifying ID token: %s", err))
-               }
-               authinfo, err := ctrl.getAuthInfo(ctx, cluster, conf, oauth2Token, idToken)
-               if err != nil {
-                       return ctrl.loginError(err)
-               }
-               ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{cluster.SystemRootToken}})
-               return railsproxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
-                       ReturnTo: state.Remote + "," + state.ReturnTo,
-                       AuthInfo: *authinfo,
-               })
-       }
-}
-
-// Use a person's token to get all of their email addresses, with the
-// primary address at index 0. The provided defaultAddr is always
-// included in the returned slice, and is used as the primary if the
-// Google API does not indicate one.
-func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arvados.Cluster, conf *oauth2.Config, token *oauth2.Token, idToken *oidc.IDToken) (*rpc.UserSessionAuthInfo, error) {
-       var ret rpc.UserSessionAuthInfo
-       defer ctxlog.FromContext(ctx).WithField("ret", &ret).Debug("getAuthInfo returned")
-
-       var claims struct {
-               Name     string `json:"name"`
-               Email    string `json:"email"`
-               Verified bool   `json:"email_verified"`
-       }
-       if err := idToken.Claims(&claims); err != nil {
-               return nil, fmt.Errorf("error extracting claims from ID token: %s", err)
-       } else if claims.Verified {
-               // Fall back to this info if the People API call
-               // (below) doesn't return a primary && verified email.
-               if names := strings.Fields(strings.TrimSpace(claims.Name)); len(names) > 1 {
-                       ret.FirstName = strings.Join(names[0:len(names)-1], " ")
-                       ret.LastName = names[len(names)-1]
-               } else {
-                       ret.FirstName = names[0]
-               }
-               ret.Email = claims.Email
-       }
-
-       if !cluster.Login.GoogleAlternateEmailAddresses {
-               if ret.Email == "" {
-                       return nil, fmt.Errorf("cannot log in with unverified email address %q", claims.Email)
-               }
-               return &ret, nil
-       }
-
-       svc, err := people.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, token)), option.WithScopes(people.UserEmailsReadScope))
-       if err != nil {
-               return nil, fmt.Errorf("error setting up People API: %s", err)
-       }
-       if p := ctrl.peopleAPIBasePath; p != "" {
-               // Override normal API endpoint (for testing)
-               svc.BasePath = p
-       }
-       person, err := people.NewPeopleService(svc).Get("people/me").PersonFields("emailAddresses,names").Do()
-       if err != nil {
-               if strings.Contains(err.Error(), "Error 403") && strings.Contains(err.Error(), "accessNotConfigured") {
-                       // Log the original API error, but display
-                       // only the "fix config" advice to the user.
-                       ctxlog.FromContext(ctx).WithError(err).WithField("email", ret.Email).Error("People API is not enabled")
-                       return nil, errors.New("configuration error: Login.GoogleAlternateEmailAddresses is true, but Google People API is not enabled")
-               } else {
-                       return nil, fmt.Errorf("error getting profile info from People API: %s", err)
-               }
-       }
-
-       // The given/family names returned by the People API and
-       // flagged as "primary" (if any) take precedence over the
-       // split-by-whitespace result from above.
-       for _, name := range person.Names {
-               if name.Metadata != nil && name.Metadata.Primary {
-                       ret.FirstName = name.GivenName
-                       ret.LastName = name.FamilyName
-                       break
-               }
-       }
-
-       altEmails := map[string]bool{}
-       if ret.Email != "" {
-               altEmails[ret.Email] = true
-       }
-       for _, ea := range person.EmailAddresses {
-               if ea.Metadata == nil || !ea.Metadata.Verified {
-                       ctxlog.FromContext(ctx).WithField("address", ea.Value).Info("skipping unverified email address")
-                       continue
-               }
-               altEmails[ea.Value] = true
-               if ea.Metadata.Primary || ret.Email == "" {
-                       ret.Email = ea.Value
-               }
-       }
-       if len(altEmails) == 0 {
-               return nil, errors.New("cannot log in without a verified email address")
-       }
-       for ae := range altEmails {
-               if ae != ret.Email {
-                       ret.AlternateEmails = append(ret.AlternateEmails, ae)
-                       if i := strings.Index(ae, "@"); i > 0 && strings.ToLower(ae[i+1:]) == strings.ToLower(cluster.Users.PreferDomainForUsername) {
-                               ret.Username = strings.SplitN(ae[:i], "+", 2)[0]
-                       }
-               }
-       }
-       return &ret, nil
-}
-
-func (ctrl *googleLoginController) loginError(sendError error) (resp arvados.LoginResponse, err error) {
-       tmpl, err := template.New("error").Parse(`<h2>Login error:</h2><p>{{.}}</p>`)
-       if err != nil {
-               return
-       }
-       err = tmpl.Execute(&resp.HTML, sendError.Error())
-       return
-}
-
-func (ctrl *googleLoginController) newOAuth2State(key []byte, remote, returnTo string) oauth2State {
-       s := oauth2State{
-               Time:     time.Now().Unix(),
-               Remote:   remote,
-               ReturnTo: returnTo,
-       }
-       s.HMAC = s.computeHMAC(key)
-       return s
-}
-
-type oauth2State struct {
-       HMAC     []byte // hash of other fields; see computeHMAC()
-       Time     int64  // creation time (unix timestamp)
-       Remote   string // remote cluster if requesting a salted token, otherwise blank
-       ReturnTo string // redirect target
-}
-
-func (ctrl *googleLoginController) parseOAuth2State(encoded string) (s oauth2State) {
-       // Errors are not checked. If decoding/parsing fails, the
-       // token will be rejected by verify().
-       decoded, _ := base64.RawURLEncoding.DecodeString(encoded)
-       f := strings.Split(string(decoded), "\n")
-       if len(f) != 4 {
-               return
-       }
-       fmt.Sscanf(f[0], "%x", &s.HMAC)
-       fmt.Sscanf(f[1], "%x", &s.Time)
-       fmt.Sscanf(f[2], "%s", &s.Remote)
-       fmt.Sscanf(f[3], "%s", &s.ReturnTo)
-       return
-}
-
-func (s oauth2State) verify(key []byte) bool {
-       if delta := time.Now().Unix() - s.Time; delta < 0 || delta > 300 {
-               return false
-       }
-       return hmac.Equal(s.computeHMAC(key), s.HMAC)
-}
-
-func (s oauth2State) String() string {
-       var buf bytes.Buffer
-       enc := base64.NewEncoder(base64.RawURLEncoding, &buf)
-       fmt.Fprintf(enc, "%x\n%x\n%s\n%s", s.HMAC, s.Time, s.Remote, s.ReturnTo)
-       enc.Close()
-       return buf.String()
-}
-
-func (s oauth2State) computeHMAC(key []byte) []byte {
-       mac := hmac.New(sha256.New, key)
-       fmt.Fprintf(mac, "%x %s %s", s.Time, s.Remote, s.ReturnTo)
-       return mac.Sum(nil)
-}
diff --git a/lib/controller/localdb/login_google.go b/lib/controller/localdb/login_google.go
new file mode 100644 (file)
index 0000000..61bbaf0
--- /dev/null
@@ -0,0 +1,285 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "bytes"
+       "context"
+       "crypto/hmac"
+       "crypto/sha256"
+       "encoding/base64"
+       "errors"
+       "fmt"
+       "net/url"
+       "strings"
+       "sync"
+       "text/template"
+       "time"
+
+       "git.arvados.org/arvados.git/lib/controller/rpc"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "github.com/coreos/go-oidc"
+       "golang.org/x/oauth2"
+       "google.golang.org/api/option"
+       "google.golang.org/api/people/v1"
+)
+
+type googleLoginController struct {
+       Cluster    *arvados.Cluster
+       RailsProxy *railsProxy
+
+       issuer            string // override OIDC issuer URL (normally https://accounts.google.com) for testing
+       peopleAPIBasePath string // override Google People API base URL (normally set by google pkg to https://people.googleapis.com/)
+       provider          *oidc.Provider
+       mu                sync.Mutex
+}
+
+func (ctrl *googleLoginController) getProvider() (*oidc.Provider, error) {
+       ctrl.mu.Lock()
+       defer ctrl.mu.Unlock()
+       if ctrl.provider == nil {
+               issuer := ctrl.issuer
+               if issuer == "" {
+                       issuer = "https://accounts.google.com"
+               }
+               provider, err := oidc.NewProvider(context.Background(), issuer)
+               if err != nil {
+                       return nil, err
+               }
+               ctrl.provider = provider
+       }
+       return ctrl.provider, nil
+}
+
+func (ctrl *googleLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+       return noopLogout(ctrl.Cluster, opts)
+}
+
+func (ctrl *googleLoginController) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
+       provider, err := ctrl.getProvider()
+       if err != nil {
+               return loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
+       }
+       redirURL, err := (*url.URL)(&ctrl.Cluster.Services.Controller.ExternalURL).Parse("/login")
+       if err != nil {
+               return loginError(fmt.Errorf("error making redirect URL: %s", err))
+       }
+       conf := &oauth2.Config{
+               ClientID:     ctrl.Cluster.Login.GoogleClientID,
+               ClientSecret: ctrl.Cluster.Login.GoogleClientSecret,
+               Endpoint:     provider.Endpoint(),
+               Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
+               RedirectURL:  redirURL.String(),
+       }
+       verifier := provider.Verifier(&oidc.Config{
+               ClientID: conf.ClientID,
+       })
+       if opts.State == "" {
+               // Initiate Google sign-in.
+               if opts.ReturnTo == "" {
+                       return loginError(errors.New("missing return_to parameter"))
+               }
+               me := url.URL(ctrl.Cluster.Services.Controller.ExternalURL)
+               callback, err := me.Parse("/" + arvados.EndpointLogin.Path)
+               if err != nil {
+                       return loginError(err)
+               }
+               conf.RedirectURL = callback.String()
+               state := ctrl.newOAuth2State([]byte(ctrl.Cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
+               return arvados.LoginResponse{
+                       RedirectLocation: conf.AuthCodeURL(state.String(),
+                               // prompt=select_account tells Google
+                               // to show the "choose which Google
+                               // account" page, even if the client
+                               // is currently logged in to exactly
+                               // one Google account.
+                               oauth2.SetAuthURLParam("prompt", "select_account")),
+               }, nil
+       } else {
+               // Callback after Google sign-in.
+               state := ctrl.parseOAuth2State(opts.State)
+               if !state.verify([]byte(ctrl.Cluster.SystemRootToken)) {
+                       return loginError(errors.New("invalid OAuth2 state"))
+               }
+               oauth2Token, err := conf.Exchange(ctx, opts.Code)
+               if err != nil {
+                       return loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
+               }
+               rawIDToken, ok := oauth2Token.Extra("id_token").(string)
+               if !ok {
+                       return loginError(errors.New("error in OAuth2 exchange: no ID token in OAuth2 token"))
+               }
+               idToken, err := verifier.Verify(ctx, rawIDToken)
+               if err != nil {
+                       return loginError(fmt.Errorf("error verifying ID token: %s", err))
+               }
+               authinfo, err := ctrl.getAuthInfo(ctx, ctrl.Cluster, conf, oauth2Token, idToken)
+               if err != nil {
+                       return loginError(err)
+               }
+               ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{ctrl.Cluster.SystemRootToken}})
+               return ctrl.RailsProxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+                       ReturnTo: state.Remote + "," + state.ReturnTo,
+                       AuthInfo: *authinfo,
+               })
+       }
+}
+
+// Use a person's token to get all of their email addresses, with the
+// primary address at index 0. The provided defaultAddr is always
+// included in the returned slice, and is used as the primary if the
+// Google API does not indicate one.
+func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arvados.Cluster, conf *oauth2.Config, token *oauth2.Token, idToken *oidc.IDToken) (*rpc.UserSessionAuthInfo, error) {
+       var ret rpc.UserSessionAuthInfo
+       defer ctxlog.FromContext(ctx).WithField("ret", &ret).Debug("getAuthInfo returned")
+
+       var claims struct {
+               Name     string `json:"name"`
+               Email    string `json:"email"`
+               Verified bool   `json:"email_verified"`
+       }
+       if err := idToken.Claims(&claims); err != nil {
+               return nil, fmt.Errorf("error extracting claims from ID token: %s", err)
+       } else if claims.Verified {
+               // Fall back to this info if the People API call
+               // (below) doesn't return a primary && verified email.
+               if names := strings.Fields(strings.TrimSpace(claims.Name)); len(names) > 1 {
+                       ret.FirstName = strings.Join(names[0:len(names)-1], " ")
+                       ret.LastName = names[len(names)-1]
+               } else {
+                       ret.FirstName = names[0]
+               }
+               ret.Email = claims.Email
+       }
+
+       if !ctrl.Cluster.Login.GoogleAlternateEmailAddresses {
+               if ret.Email == "" {
+                       return nil, fmt.Errorf("cannot log in with unverified email address %q", claims.Email)
+               }
+               return &ret, nil
+       }
+
+       svc, err := people.NewService(ctx, option.WithTokenSource(conf.TokenSource(ctx, token)), option.WithScopes(people.UserEmailsReadScope))
+       if err != nil {
+               return nil, fmt.Errorf("error setting up People API: %s", err)
+       }
+       if p := ctrl.peopleAPIBasePath; p != "" {
+               // Override normal API endpoint (for testing)
+               svc.BasePath = p
+       }
+       person, err := people.NewPeopleService(svc).Get("people/me").PersonFields("emailAddresses,names").Do()
+       if err != nil {
+               if strings.Contains(err.Error(), "Error 403") && strings.Contains(err.Error(), "accessNotConfigured") {
+                       // Log the original API error, but display
+                       // only the "fix config" advice to the user.
+                       ctxlog.FromContext(ctx).WithError(err).WithField("email", ret.Email).Error("People API is not enabled")
+                       return nil, errors.New("configuration error: Login.GoogleAlternateEmailAddresses is true, but Google People API is not enabled")
+               } else {
+                       return nil, fmt.Errorf("error getting profile info from People API: %s", err)
+               }
+       }
+
+       // The given/family names returned by the People API and
+       // flagged as "primary" (if any) take precedence over the
+       // split-by-whitespace result from above.
+       for _, name := range person.Names {
+               if name.Metadata != nil && name.Metadata.Primary {
+                       ret.FirstName = name.GivenName
+                       ret.LastName = name.FamilyName
+                       break
+               }
+       }
+
+       altEmails := map[string]bool{}
+       if ret.Email != "" {
+               altEmails[ret.Email] = true
+       }
+       for _, ea := range person.EmailAddresses {
+               if ea.Metadata == nil || !ea.Metadata.Verified {
+                       ctxlog.FromContext(ctx).WithField("address", ea.Value).Info("skipping unverified email address")
+                       continue
+               }
+               altEmails[ea.Value] = true
+               if ea.Metadata.Primary || ret.Email == "" {
+                       ret.Email = ea.Value
+               }
+       }
+       if len(altEmails) == 0 {
+               return nil, errors.New("cannot log in without a verified email address")
+       }
+       for ae := range altEmails {
+               if ae != ret.Email {
+                       ret.AlternateEmails = append(ret.AlternateEmails, ae)
+                       if i := strings.Index(ae, "@"); i > 0 && strings.ToLower(ae[i+1:]) == strings.ToLower(ctrl.Cluster.Users.PreferDomainForUsername) {
+                               ret.Username = strings.SplitN(ae[:i], "+", 2)[0]
+                       }
+               }
+       }
+       return &ret, nil
+}
+
+func loginError(sendError error) (resp arvados.LoginResponse, err error) {
+       tmpl, err := template.New("error").Parse(`<h2>Login error:</h2><p>{{.}}</p>`)
+       if err != nil {
+               return
+       }
+       err = tmpl.Execute(&resp.HTML, sendError.Error())
+       return
+}
+
+func (ctrl *googleLoginController) newOAuth2State(key []byte, remote, returnTo string) oauth2State {
+       s := oauth2State{
+               Time:     time.Now().Unix(),
+               Remote:   remote,
+               ReturnTo: returnTo,
+       }
+       s.HMAC = s.computeHMAC(key)
+       return s
+}
+
+type oauth2State struct {
+       HMAC     []byte // hash of other fields; see computeHMAC()
+       Time     int64  // creation time (unix timestamp)
+       Remote   string // remote cluster if requesting a salted token, otherwise blank
+       ReturnTo string // redirect target
+}
+
+func (ctrl *googleLoginController) parseOAuth2State(encoded string) (s oauth2State) {
+       // Errors are not checked. If decoding/parsing fails, the
+       // token will be rejected by verify().
+       decoded, _ := base64.RawURLEncoding.DecodeString(encoded)
+       f := strings.Split(string(decoded), "\n")
+       if len(f) != 4 {
+               return
+       }
+       fmt.Sscanf(f[0], "%x", &s.HMAC)
+       fmt.Sscanf(f[1], "%x", &s.Time)
+       fmt.Sscanf(f[2], "%s", &s.Remote)
+       fmt.Sscanf(f[3], "%s", &s.ReturnTo)
+       return
+}
+
+func (s oauth2State) verify(key []byte) bool {
+       if delta := time.Now().Unix() - s.Time; delta < 0 || delta > 300 {
+               return false
+       }
+       return hmac.Equal(s.computeHMAC(key), s.HMAC)
+}
+
+func (s oauth2State) String() string {
+       var buf bytes.Buffer
+       enc := base64.NewEncoder(base64.RawURLEncoding, &buf)
+       fmt.Fprintf(enc, "%x\n%x\n%s\n%s", s.HMAC, s.Time, s.Remote, s.ReturnTo)
+       enc.Close()
+       return buf.String()
+}
+
+func (s oauth2State) computeHMAC(key []byte) []byte {
+       mac := hmac.New(sha256.New, key)
+       fmt.Fprintf(mac, "%x %s %s", s.Time, s.Remote, s.ReturnTo)
+       return mac.Sum(nil)
+}
similarity index 94%
rename from lib/controller/localdb/login_test.go
rename to lib/controller/localdb/login_google_test.go
index db6daa195b226eb5ea36661909358edfeb263dbf..9e16e2e90439a8ab7767930b6c701fe6d6ab604a 100644 (file)
@@ -154,11 +154,11 @@ func (s *LoginSuite) SetUpTest(c *check.C) {
        c.Assert(err, check.IsNil)
 
        s.localdb = NewConn(s.cluster)
-       s.localdb.googleLoginController.issuer = s.fakeIssuer.URL
-       s.localdb.googleLoginController.peopleAPIBasePath = s.fakePeopleAPI.URL
+       s.localdb.loginController.(*googleLoginController).issuer = s.fakeIssuer.URL
+       s.localdb.loginController.(*googleLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
 
        s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
-       s.localdb.railsProxy = rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
+       *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
 }
 
 func (s *LoginSuite) TearDownTest(c *check.C) {
@@ -188,7 +188,7 @@ func (s *LoginSuite) TestGoogleLogin_Start(c *check.C) {
                c.Check(target.Host, check.Equals, issuerURL.Host)
                q := target.Query()
                c.Check(q.Get("client_id"), check.Equals, "test%client$id")
-               state := s.localdb.googleLoginController.parseOAuth2State(q.Get("state"))
+               state := s.localdb.loginController.(*googleLoginController).parseOAuth2State(q.Get("state"))
                c.Check(state.verify([]byte(s.cluster.SystemRootToken)), check.Equals, true)
                c.Check(state.Time, check.Not(check.Equals), 0)
                c.Check(state.Remote, check.Equals, remote)
@@ -223,7 +223,7 @@ func (s *LoginSuite) setupPeopleAPIError(c *check.C) {
                w.WriteHeader(http.StatusForbidden)
                fmt.Fprintln(w, `Error 403: accessNotConfigured`)
        }))
-       s.localdb.googleLoginController.peopleAPIBasePath = s.fakePeopleAPI.URL
+       s.localdb.loginController.(*googleLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
 }
 
 func (s *LoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
@@ -236,7 +236,7 @@ func (s *LoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
                State: state,
        })
        c.Check(err, check.IsNil)
-       authinfo := s.getCallbackAuthInfo(c)
+       authinfo := getCallbackAuthInfo(c, s.railsSpy)
        c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
 }
 
@@ -266,7 +266,7 @@ func (s *LoginSuite) TestGoogleLogin_Success(c *check.C) {
        token := target.Query().Get("api_token")
        c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`)
 
-       authinfo := s.getCallbackAuthInfo(c)
+       authinfo := getCallbackAuthInfo(c, s.railsSpy)
        c.Check(authinfo.FirstName, check.Equals, "Fake User")
        c.Check(authinfo.LastName, check.Equals, "Name")
        c.Check(authinfo.Email, check.Equals, "active-user@arvados.local")
@@ -312,7 +312,7 @@ func (s *LoginSuite) TestGoogleLogin_RealName(c *check.C) {
                State: state,
        })
 
-       authinfo := s.getCallbackAuthInfo(c)
+       authinfo := getCallbackAuthInfo(c, s.railsSpy)
        c.Check(authinfo.FirstName, check.Equals, "Joseph")
        c.Check(authinfo.LastName, check.Equals, "Psmith")
 }
@@ -326,7 +326,7 @@ func (s *LoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) {
                State: state,
        })
 
-       authinfo := s.getCallbackAuthInfo(c)
+       authinfo := getCallbackAuthInfo(c, s.railsSpy)
        c.Check(authinfo.FirstName, check.Equals, "Joe P.")
        c.Check(authinfo.LastName, check.Equals, "Smith")
 }
@@ -355,7 +355,7 @@ func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
                State: state,
        })
 
-       authinfo := s.getCallbackAuthInfo(c)
+       authinfo := getCallbackAuthInfo(c, s.railsSpy)
        c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
        c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com", "joe.smith@work.example.com"})
 }
@@ -384,7 +384,7 @@ func (s *LoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C)
                Code:  s.validCode,
                State: state,
        })
-       authinfo := s.getCallbackAuthInfo(c)
+       authinfo := getCallbackAuthInfo(c, s.railsSpy)
        c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
        c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@alternate.example.com", "jsmith+123@preferdomainforusername.example.com"})
        c.Check(authinfo.Username, check.Equals, "jsmith")
@@ -411,30 +411,12 @@ func (s *LoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
                State: state,
        })
 
-       authinfo := s.getCallbackAuthInfo(c)
+       authinfo := getCallbackAuthInfo(c, s.railsSpy)
        c.Check(authinfo.Email, check.Equals, "joe.smith@work.example.com") // first verified email in People response
        c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com"})
        c.Check(authinfo.Username, check.Equals, "")
 }
 
-func (s *LoginSuite) getCallbackAuthInfo(c *check.C) (authinfo rpc.UserSessionAuthInfo) {
-       for _, dump := range s.railsSpy.RequestDumps {
-               c.Logf("spied request: %q", dump)
-               split := bytes.Split(dump, []byte("\r\n\r\n"))
-               c.Assert(split, check.HasLen, 2)
-               hdr, body := string(split[0]), string(split[1])
-               if strings.Contains(hdr, "POST /auth/controller/callback") {
-                       vs, err := url.ParseQuery(body)
-                       c.Check(json.Unmarshal([]byte(vs.Get("auth_info")), &authinfo), check.IsNil)
-                       c.Check(err, check.IsNil)
-                       sort.Strings(authinfo.AlternateEmails)
-                       return
-               }
-       }
-       c.Error("callback not found")
-       return
-}
-
 func (s *LoginSuite) startLogin(c *check.C) (state string) {
        // Initiate login, but instead of following the redirect to
        // the provider, just grab state from the redirect URL.
@@ -463,3 +445,21 @@ func (s *LoginSuite) fakeToken(c *check.C, payload []byte) string {
        c.Logf("fakeToken(%q) == %q", payload, t)
        return t
 }
+
+func getCallbackAuthInfo(c *check.C, railsSpy *arvadostest.Proxy) (authinfo rpc.UserSessionAuthInfo) {
+       for _, dump := range railsSpy.RequestDumps {
+               c.Logf("spied request: %q", dump)
+               split := bytes.Split(dump, []byte("\r\n\r\n"))
+               c.Assert(split, check.HasLen, 2)
+               hdr, body := string(split[0]), string(split[1])
+               if strings.Contains(hdr, "POST /auth/controller/callback") {
+                       vs, err := url.ParseQuery(body)
+                       c.Check(json.Unmarshal([]byte(vs.Get("auth_info")), &authinfo), check.IsNil)
+                       c.Check(err, check.IsNil)
+                       sort.Strings(authinfo.AlternateEmails)
+                       return
+               }
+       }
+       c.Error("callback not found")
+       return
+}
diff --git a/lib/controller/localdb/login_pam.go b/lib/controller/localdb/login_pam.go
new file mode 100644 (file)
index 0000000..1b1a053
--- /dev/null
@@ -0,0 +1,85 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+       "fmt"
+       "net/url"
+       "strings"
+
+       "git.arvados.org/arvados.git/lib/controller/rpc"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/auth"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       "github.com/msteinert/pam"
+       "github.com/sirupsen/logrus"
+)
+
+type pamLoginController struct {
+       Cluster    *arvados.Cluster
+       RailsProxy *railsProxy
+}
+
+func (ctrl *pamLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+       return noopLogout(ctrl.Cluster, opts)
+}
+
+func (ctrl *pamLoginController) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
+       errorMessage := ""
+       tx, err := pam.StartFunc(ctrl.Cluster.Login.PAMService, opts.Username, func(style pam.Style, message string) (string, error) {
+               ctxlog.FromContext(ctx).Debugf("pam conversation: style=%v message=%q", style, message)
+               switch style {
+               case pam.ErrorMsg:
+                       ctxlog.FromContext(ctx).WithField("Message", message).Info("pam.ErrorMsg")
+                       errorMessage = message
+                       return "", nil
+               case pam.TextInfo:
+                       ctxlog.FromContext(ctx).WithField("Message", message).Info("pam.TextInfo")
+                       return "", nil
+               case pam.PromptEchoOn, pam.PromptEchoOff:
+                       return opts.Password, nil
+               default:
+                       return "", fmt.Errorf("unrecognized message style %d", style)
+               }
+       })
+       if err != nil {
+               return arvados.LoginResponse{Message: err.Error()}, nil
+       }
+       err = tx.Authenticate(pam.DisallowNullAuthtok)
+       if err != nil {
+               return arvados.LoginResponse{Message: err.Error()}, nil
+       }
+       if errorMessage != "" {
+               return arvados.LoginResponse{Message: errorMessage}, nil
+       }
+       user, err := tx.GetItem(pam.User)
+       if err != nil {
+               return arvados.LoginResponse{Message: err.Error()}, nil
+       }
+       email := user
+       if domain := ctrl.Cluster.Login.PAMDefaultEmailDomain; domain != "" && !strings.Contains(email, "@") {
+               email = email + "@" + domain
+       }
+       ctxlog.FromContext(ctx).WithFields(logrus.Fields{"user": user, "email": email}).Debug("pam authentication succeeded")
+       ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{ctrl.Cluster.SystemRootToken}})
+       resp, err := ctrl.RailsProxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+               ReturnTo: opts.Remote + "," + opts.ReturnTo,
+               AuthInfo: rpc.UserSessionAuthInfo{
+                       Username: user,
+                       Email:    email,
+               },
+       })
+       if err != nil {
+               return arvados.LoginResponse{Message: err.Error()}, nil
+       }
+       target, err := url.Parse(resp.RedirectLocation)
+       if err != nil {
+               return arvados.LoginResponse{Message: err.Error()}, nil
+       }
+       resp.Token = target.Query().Get("api_token")
+       resp.RedirectLocation = ""
+       return resp, err
+}
diff --git a/lib/controller/localdb/login_pam_test.go b/lib/controller/localdb/login_pam_test.go
new file mode 100644 (file)
index 0000000..885aa86
--- /dev/null
@@ -0,0 +1,84 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+       "context"
+       "io/ioutil"
+       "os"
+       "strings"
+
+       "git.arvados.org/arvados.git/lib/config"
+       "git.arvados.org/arvados.git/lib/controller/rpc"
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       "git.arvados.org/arvados.git/sdk/go/ctxlog"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&PamSuite{})
+
+type PamSuite struct {
+       cluster  *arvados.Cluster
+       ctrl     *pamLoginController
+       railsSpy *arvadostest.Proxy
+}
+
+func (s *PamSuite) SetUpSuite(c *check.C) {
+       cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+       c.Assert(err, check.IsNil)
+       s.cluster, err = cfg.GetCluster("")
+       c.Assert(err, check.IsNil)
+       s.cluster.Login.PAM = true
+       s.cluster.Login.PAMDefaultEmailDomain = "example.com"
+       s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
+       s.ctrl = &pamLoginController{
+               Cluster:    s.cluster,
+               RailsProxy: rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider),
+       }
+}
+
+func (s *PamSuite) TestLoginFailure(c *check.C) {
+       resp, err := s.ctrl.Login(context.Background(), arvados.LoginOptions{
+               Username: "bogususername",
+               Password: "boguspassword",
+               ReturnTo: "https://example.com/foo",
+       })
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, "")
+       c.Check(resp.Token, check.Equals, "")
+       c.Check(resp.Message, check.Equals, "Authentication failure")
+       c.Check(resp.HTML.String(), check.Equals, "")
+}
+
+// This test only runs if the ARVADOS_TEST_PAM_CREDENTIALS_FILE env
+// var is set. The credentials file should contain a valid username
+// and password, separated by \n.
+func (s *PamSuite) TestLoginSuccess(c *check.C) {
+       testCredsFile := os.Getenv("ARVADOS_TEST_PAM_CREDENTIALS_FILE")
+       if testCredsFile == "" {
+               c.Skip("no test credentials file given in ARVADOS_TEST_PAM_CREDENTIALS_FILE")
+               return
+       }
+       buf, err := ioutil.ReadFile(testCredsFile)
+       c.Assert(err, check.IsNil)
+       lines := strings.Split(string(buf), "\n")
+       c.Assert(len(lines), check.Equals, 2, check.Commentf("credentials file %s should contain \"username\\npassword\"", testCredsFile))
+       u, p := lines[0], lines[1]
+
+       resp, err := s.ctrl.Login(context.Background(), arvados.LoginOptions{
+               Username: u,
+               Password: p,
+               ReturnTo: "https://example.com/foo",
+       })
+       c.Check(err, check.IsNil)
+       c.Check(resp.RedirectLocation, check.Equals, "")
+       c.Check(resp.Token, check.Matches, `v2/zzzzz-gj3su-.*/.*`)
+       c.Check(resp.HTML.String(), check.Equals, "")
+
+       authinfo := getCallbackAuthInfo(c, s.railsSpy)
+       c.Check(authinfo.Email, check.Equals, u+"@"+s.cluster.Login.PAMDefaultEmailDomain)
+       c.Check(authinfo.AlternateEmails, check.DeepEquals, []string(nil))
+}
index 0c5d32e8b7e1e96bdab9bbf45b25962963297829..ff0dcf75a7ee8dfb37b38222e53fae1a59f2ef77 100644 (file)
@@ -136,10 +136,12 @@ type DeleteOptions struct {
 }
 
 type LoginOptions struct {
-       ReturnTo string `json:"return_to"`        // On success, redirect to this target with api_token=xxx query param
-       Remote   string `json:"remote,omitempty"` // Salt token for remote Cluster ID
-       Code     string `json:"code,omitempty"`   // OAuth2 callback code
-       State    string `json:"state,omitempty"`  // OAuth2 callback state
+       ReturnTo string `json:"return_to"`          // On success, redirect to this target with api_token=xxx query param
+       Remote   string `json:"remote,omitempty"`   // Salt token for remote Cluster ID
+       Code     string `json:"code,omitempty"`     // OAuth2 callback code
+       State    string `json:"state,omitempty"`    // OAuth2 callback state
+       Username string `json:"username,omitempty"` // PAM username
+       Password string `json:"password,omitempty"` // PAM password
 }
 
 type LogoutOptions struct {
index 67e3f631174ce86741190a25aa4ce8a63864fd1f..1e2c07e867e84d6d6719fdd4f9298b005a86c6e9 100644 (file)
@@ -186,7 +186,7 @@ func (c *Client) DoAndDecode(dst interface{}, req *http.Request) error {
                return nil
        case isRedirectStatus(resp.StatusCode):
                // Copy the redirect target URL to dst.RedirectLocation.
-               buf, err := json.Marshal(map[string]string{"RedirectLocation": resp.Header.Get("Location")})
+               buf, err := json.Marshal(map[string]string{"redirect_location": resp.Header.Get("Location")})
                if err != nil {
                        return err
                }
index a70980cbde232cc562155bbfb813d0f1cf32afbc..71f6f85bff4754aa5d9d038fd4fb8b900d4f8cd8 100644 (file)
@@ -136,6 +136,9 @@ type Cluster struct {
                GoogleClientID                string
                GoogleClientSecret            string
                GoogleAlternateEmailAddresses bool
+               PAM                           bool
+               PAMService                    string
+               PAMDefaultEmailDomain         string
                ProviderAppID                 string
                ProviderAppSecret             string
                LoginCluster                  string
index 75ebc81c142b1ee167bafa7792923513af3c5120..579c1083799888db480f0d3cd5cd1522a7f8a084 100644 (file)
@@ -6,12 +6,15 @@ package arvados
 
 import (
        "bytes"
+       "encoding/json"
        "net/http"
 )
 
 type LoginResponse struct {
-       RedirectLocation string
-       HTML             bytes.Buffer
+       RedirectLocation string       `json:"redirect_location,omitempty"`
+       Token            string       `json:"token,omitempty"`
+       Message          string       `json:"message,omitempty"`
+       HTML             bytes.Buffer `json:"-"`
 }
 
 func (resp LoginResponse) ServeHTTP(w http.ResponseWriter, req *http.Request) {
@@ -19,6 +22,9 @@ func (resp LoginResponse) ServeHTTP(w http.ResponseWriter, req *http.Request) {
        if resp.RedirectLocation != "" {
                w.Header().Set("Location", resp.RedirectLocation)
                w.WriteHeader(http.StatusFound)
+       } else if resp.Token != "" || resp.Message != "" {
+               w.Header().Set("Content-Type", "application/json")
+               json.NewEncoder(w).Encode(resp)
        } else {
                w.Header().Set("Content-Type", "text/html")
                w.Write(resp.HTML.Bytes())