[ARVADOS] created: 1.3.0-2344-ge977431a3
Git user
git at public.arvados.org
Thu Mar 12 20:11:10 UTC 2020
at e977431a341020ab7dd7e4238786fa7dea5c9538 (commit)
commit e977431a341020ab7dd7e4238786fa7dea5c9538
Author: Tom Clegg <tom at tomclegg.ca>
Date: Thu Mar 12 16:11:05 2020 -0400
16212: Support username/password authentication via PAM.
Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>
diff --git a/build/run-tests.sh b/build/run-tests.sh
index 7328fad45..106793f7a 100755
--- a/build/run-tests.sh
+++ b/build/run-tests.sh
@@ -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 2cc5e89eb..4491b3598 100644
--- 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 c3904fe84..18cf89b0e 100644
--- 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=
diff --git a/lib/config/config.default.yml b/lib/config/config.default.yml
index 411296cbe..d8d254170 100644
--- a/lib/config/config.default.yml
+++ b/lib/config/config.default.yml
@@ -541,6 +541,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)
diff --git a/lib/config/export.go b/lib/config/export.go
index 5973a16a1..ded03fc30 100644
--- a/lib/config/export.go
+++ b/lib/config/export.go
@@ -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,
diff --git a/lib/config/generated_config.go b/lib/config/generated_config.go
index f40093a96..368569103 100644
--- a/lib/config/generated_config.go
+++ b/lib/config/generated_config.go
@@ -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)
diff --git a/lib/controller/localdb/conn.go b/lib/controller/localdb/conn.go
index ac092382d..909b6e1ff 100644
--- a/lib/controller/localdb/conn.go
+++ b/lib/controller/localdb/conn.go
@@ -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)
}
diff --git a/lib/controller/localdb/login.go b/lib/controller/localdb/login.go
index 2e50b84f4..af9a03482 100644
--- a/lib/controller/localdb/login.go
+++ b/lib/controller/localdb/login.go
@@ -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.go b/lib/controller/localdb/login_google.go
similarity index 79%
copy from lib/controller/localdb/login.go
copy to lib/controller/localdb/login_google.go
index 2e50b84f4..61bbaf01b 100644
--- a/lib/controller/localdb/login.go
+++ b/lib/controller/localdb/login_google.go
@@ -29,6 +29,9 @@ import (
)
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
@@ -52,30 +55,22 @@ func (ctrl *googleLoginController) getProvider() (*oidc.Provider, error) {
return ctrl.provider, nil
}
-func (ctrl *googleLoginController) Logout(ctx context.Context, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
- target := opts.ReturnTo
- if target == "" {
- if cluster.Services.Workbench2.ExternalURL.Host != "" {
- target = cluster.Services.Workbench2.ExternalURL.String()
- } else {
- target = cluster.Services.Workbench1.ExternalURL.String()
- }
- }
- return arvados.LogoutResponse{RedirectLocation: target}, 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, cluster *arvados.Cluster, railsproxy *railsProxy, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
+func (ctrl *googleLoginController) Login(ctx context.Context, 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))
+ return loginError(fmt.Errorf("error setting up OpenID Connect provider: %s", err))
}
- redirURL, err := (*url.URL)(&cluster.Services.Controller.ExternalURL).Parse("/login")
+ redirURL, err := (*url.URL)(&ctrl.Cluster.Services.Controller.ExternalURL).Parse("/login")
if err != nil {
- return ctrl.loginError(fmt.Errorf("error making redirect URL: %s", err))
+ return loginError(fmt.Errorf("error making redirect URL: %s", err))
}
conf := &oauth2.Config{
- ClientID: cluster.Login.GoogleClientID,
- ClientSecret: cluster.Login.GoogleClientSecret,
+ ClientID: ctrl.Cluster.Login.GoogleClientID,
+ ClientSecret: ctrl.Cluster.Login.GoogleClientSecret,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
RedirectURL: redirURL.String(),
@@ -86,15 +81,15 @@ func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.C
if opts.State == "" {
// Initiate Google sign-in.
if opts.ReturnTo == "" {
- return ctrl.loginError(errors.New("missing return_to parameter"))
+ return loginError(errors.New("missing return_to parameter"))
}
- me := url.URL(cluster.Services.Controller.ExternalURL)
+ me := url.URL(ctrl.Cluster.Services.Controller.ExternalURL)
callback, err := me.Parse("/" + arvados.EndpointLogin.Path)
if err != nil {
- return ctrl.loginError(err)
+ return loginError(err)
}
conf.RedirectURL = callback.String()
- state := ctrl.newOAuth2State([]byte(cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
+ state := ctrl.newOAuth2State([]byte(ctrl.Cluster.SystemRootToken), opts.Remote, opts.ReturnTo)
return arvados.LoginResponse{
RedirectLocation: conf.AuthCodeURL(state.String(),
// prompt=select_account tells Google
@@ -107,27 +102,27 @@ func (ctrl *googleLoginController) Login(ctx context.Context, cluster *arvados.C
} 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"))
+ 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 ctrl.loginError(fmt.Errorf("error in OAuth2 exchange: %s", err))
+ return 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"))
+ return 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))
+ return loginError(fmt.Errorf("error verifying ID token: %s", err))
}
- authinfo, err := ctrl.getAuthInfo(ctx, cluster, conf, oauth2Token, idToken)
+ authinfo, err := ctrl.getAuthInfo(ctx, ctrl.Cluster, conf, oauth2Token, idToken)
if err != nil {
- return ctrl.loginError(err)
+ return loginError(err)
}
- ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{cluster.SystemRootToken}})
- return railsproxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+ 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,
})
@@ -161,7 +156,7 @@ func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arv
ret.Email = claims.Email
}
- if !cluster.Login.GoogleAlternateEmailAddresses {
+ if !ctrl.Cluster.Login.GoogleAlternateEmailAddresses {
if ret.Email == "" {
return nil, fmt.Errorf("cannot log in with unverified email address %q", claims.Email)
}
@@ -219,7 +214,7 @@ func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arv
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) {
+ 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]
}
}
@@ -227,7 +222,7 @@ func (ctrl *googleLoginController) getAuthInfo(ctx context.Context, cluster *arv
return &ret, nil
}
-func (ctrl *googleLoginController) loginError(sendError error) (resp arvados.LoginResponse, err error) {
+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
diff --git a/lib/controller/localdb/login_test.go b/lib/controller/localdb/login_google_test.go
similarity index 94%
rename from lib/controller/localdb/login_test.go
rename to lib/controller/localdb/login_google_test.go
index db6daa195..75fff3372 100644
--- a/lib/controller/localdb/login_test.go
+++ b/lib/controller/localdb/login_google_test.go
@@ -154,8 +154,8 @@ 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)
@@ -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 at 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 at 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 at primary.example.com")
c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith at home.example.com", "joe.smith at 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 at primary.example.com")
c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith at alternate.example.com", "jsmith+123 at 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 at work.example.com") // first verified email in People response
c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith at 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
index 000000000..51534ee54
--- /dev/null
+++ b/lib/controller/localdb/login_pam.go
@@ -0,0 +1,75 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "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 loginError(err)
+ }
+ err = tx.Authenticate(pam.DisallowNullAuthtok)
+ if err != nil {
+ return loginError(err)
+ }
+ if errorMessage != "" {
+ return loginError(errors.New(errorMessage))
+ }
+ user, err := tx.GetItem(pam.User)
+ if err != nil {
+ return loginError(err)
+ }
+ 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}})
+ return ctrl.RailsProxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+ ReturnTo: opts.Remote + "," + opts.ReturnTo,
+ AuthInfo: rpc.UserSessionAuthInfo{
+ Username: user,
+ Email: email,
+ },
+ })
+}
diff --git a/lib/controller/localdb/login_pam_test.go b/lib/controller/localdb/login_pam_test.go
new file mode 100644
index 000000000..16e86b534
--- /dev/null
+++ b/lib/controller/localdb/login_pam_test.go
@@ -0,0 +1,81 @@
+// 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.HTML.String(), check.Matches, `.*Authentication failure.*`)
+}
+
+// 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.Matches, `https://example.com/foo\?api_token=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))
+}
diff --git a/sdk/go/arvados/api.go b/sdk/go/arvados/api.go
index 0c5d32e8b..ff0dcf75a 100644
--- a/sdk/go/arvados/api.go
+++ b/sdk/go/arvados/api.go
@@ -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 {
diff --git a/sdk/go/arvados/config.go b/sdk/go/arvados/config.go
index a70980cbd..71f6f85bf 100644
--- a/sdk/go/arvados/config.go
+++ b/sdk/go/arvados/config.go
@@ -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
-----------------------------------------------------------------------
hooks/post-receive
--
More information about the arvados-commits
mailing list