MAINTAINER Arvados Package Maintainers <packaging@arvados.org>
# Install dependencies.
-RUN yum -q -y install make automake gcc gcc-c++ libyaml-devel patch readline-devel zlib-devel libffi-devel openssl-devel bzip2 libtool bison sqlite-devel rpm-build git perl-ExtUtils-MakeMaker libattr-devel nss-devel libcurl-devel which tar unzip scl-utils centos-release-scl postgresql-devel python-devel python-setuptools fuse-devel xz-libs git python-virtualenv wget
+RUN yum -q -y install make automake gcc gcc-c++ libyaml-devel patch readline-devel zlib-devel libffi-devel openssl-devel bzip2 libtool bison sqlite-devel rpm-build git perl-ExtUtils-MakeMaker libattr-devel nss-devel libcurl-devel which tar unzip scl-utils centos-release-scl postgresql-devel python-devel python-setuptools fuse-devel xz-libs git python-virtualenv wget pam-devel
# Install RVM
ADD generated/mpapis.asc /tmp/
ENV DEBIAN_FRONTEND noninteractive
# Install dependencies.
-RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python2.7-dev python3 python-setuptools python3-setuptools python3-pip libcurl4-gnutls-dev curl git procps libattr1-dev libfuse-dev libgnutls28-dev libpq-dev python-pip unzip python3-venv python3-dev
+RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python2.7-dev python3 python-setuptools python3-setuptools python3-pip libcurl4-gnutls-dev curl git procps libattr1-dev libfuse-dev libgnutls28-dev libpq-dev python-pip unzip python3-venv python3-dev libpam-dev
# Install virtualenv
RUN /usr/bin/pip install 'virtualenv<20'
ENV DEBIAN_FRONTEND noninteractive
# Install dependencies.
-RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python2.7-dev python3 python-setuptools python3-setuptools python3-pip libcurl4-gnutls-dev curl git procps libattr1-dev libfuse-dev libgnutls28-dev libpq-dev python-pip unzip python3-venv python3-dev
+RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python2.7-dev python3 python-setuptools python3-setuptools python3-pip libcurl4-gnutls-dev curl git procps libattr1-dev libfuse-dev libgnutls28-dev libpq-dev python-pip unzip python3-venv python3-dev libpam-dev
# Install virtualenv
RUN /usr/bin/pip install 'virtualenv<20'
ENV DEBIAN_FRONTEND noninteractive
# Install dependencies.
-RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python2.7-dev python3 python-setuptools python3-setuptools python3-pip libcurl4-gnutls-dev libgnutls-dev curl git libattr1-dev libfuse-dev libpq-dev python-pip unzip tzdata python3-venv python3-dev
+RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python2.7-dev python3 python-setuptools python3-setuptools python3-pip libcurl4-gnutls-dev libgnutls-dev curl git libattr1-dev libfuse-dev libpq-dev python-pip unzip tzdata python3-venv python3-dev libpam-dev
# Install virtualenv
RUN /usr/bin/pip install 'virtualenv<20'
ENV DEBIAN_FRONTEND noninteractive
# Install dependencies.
-RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python2.7-dev python3 python-setuptools python3-pip libcurl4-gnutls-dev libgnutls28-dev curl git libattr1-dev libfuse-dev libpq-dev python-pip unzip tzdata python3-venv python3-dev
+RUN /usr/bin/apt-get update && /usr/bin/apt-get install -q -y python2.7-dev python3 python-setuptools python3-pip libcurl4-gnutls-dev libgnutls28-dev curl git libattr1-dev libfuse-dev libpq-dev python-pip unzip tzdata python3-venv python3-dev libpam-dev
# Install virtualenv
RUN /usr/bin/pip install 'virtualenv<20'
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: '
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
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=
# 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)
"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,
# 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)
return conn.local.UserBatchUpdate(ctx, options)
}
+func (conn *Conn) UserAuthenticate(ctx context.Context, options arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
+ return conn.local.UserAuthenticate(ctx, options)
+}
+
func (conn *Conn) APIClientAuthorizationCurrent(ctx context.Context, options arvados.GetOptions) (arvados.APIClientAuthorization, error) {
return conn.chooseBackend(options.UUID).APIClientAuthorizationCurrent(ctx, options)
}
s.cluster.Login.GoogleClientID = "zzzzzzzzzzzzzz"
s.addHTTPRemote(c, "zhome", &arvadostest.APIStub{})
s.cluster.Login.LoginCluster = "zhome"
+ // s.fed is already set by SetUpTest, but we need to
+ // reinitialize with the above config changes.
+ s.fed = New(s.cluster)
returnTo := "https://app.example.com/foo?bar"
for _, trial := range []struct {
rtr := router.New(federation.New(h.Cluster))
mux.Handle("/arvados/v1/config", rtr)
+ mux.Handle("/"+arvados.EndpointUserAuthenticate.Path, rtr)
if !h.Cluster.ForceLegacyAPI14 {
mux.Handle("/arvados/v1/collections", rtr)
import (
"context"
- "errors"
"git.arvados.org/arvados.git/lib/controller/railsproxy"
"git.arvados.org/arvados.git/lib/controller/rpc"
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)
+}
+
+func (conn *Conn) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
+ return conn.loginController.UserAuthenticate(ctx, opts)
}
package localdb
import (
- "bytes"
"context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/base64"
"errors"
- "fmt"
- "net/url"
- "strings"
- "sync"
- "text/template"
- "time"
+ "net/http"
- "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"
+ "git.arvados.org/arvados.git/sdk/go/httpserver"
)
-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)
+ UserAuthenticate(ctx context.Context, options arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, 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 &ssoLoginController{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) {
- 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
-}
+// Login and Logout are passed through to the wrapped railsProxy;
+// UserAuthenticate is rejected.
+type ssoLoginController struct{ *railsProxy }
-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,
- })
- }
+func (ctrl *ssoLoginController) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
+ return arvados.APIClientAuthorization{}, httpserver.ErrorWithStatus(errors.New("username/password authentication is not available"), http.StatusBadRequest)
}
-// 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")
+type errorLoginController struct{ error }
- 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 errorLoginController) Login(context.Context, arvados.LoginOptions) (arvados.LoginResponse, error) {
+ return arvados.LoginResponse{}, ctrl.error
}
-
-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 errorLoginController) Logout(context.Context, arvados.LogoutOptions) (arvados.LogoutResponse, error) {
+ return arvados.LogoutResponse{}, ctrl.error
}
-
-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
+func (ctrl errorLoginController) UserAuthenticate(context.Context, arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
+ return arvados.APIClientAuthorization{}, ctrl.error
}
-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
+func noopLogout(cluster *arvados.Cluster, 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 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)
+ return arvados.LogoutResponse{RedirectLocation: target}, nil
}
--- /dev/null
+// 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/http"
+ "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"
+ "git.arvados.org/arvados.git/sdk/go/httpserver"
+ "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,
+ })
+ }
+}
+
+func (ctrl *googleLoginController) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
+ return arvados.APIClientAuthorization{}, httpserver.ErrorWithStatus(errors.New("username/password authentication is not available"), http.StatusBadRequest)
+}
+
+// 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)
+}
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) {
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)
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) {
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")
}
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")
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")
}
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")
}
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"})
}
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")
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.
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
+}
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "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"
+ "git.arvados.org/arvados.git/sdk/go/httpserver"
+ "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) {
+ return arvados.LoginResponse{}, errors.New("interactive login is not available")
+}
+
+func (ctrl *pamLoginController) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
+ errorMessage := ""
+ sentPassword := false
+ 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:
+ sentPassword = true
+ return opts.Password, nil
+ default:
+ return "", fmt.Errorf("unrecognized message style %d", style)
+ }
+ })
+ if err != nil {
+ return arvados.APIClientAuthorization{}, err
+ }
+ err = tx.Authenticate(pam.DisallowNullAuthtok)
+ if err != nil {
+ err = fmt.Errorf("PAM: %s", err)
+ if errorMessage != "" {
+ // Perhaps the error message in the
+ // conversation is helpful.
+ err = fmt.Errorf("%s; %q", err, errorMessage)
+ }
+ if sentPassword {
+ err = fmt.Errorf("%s (with username %q and password)", err, opts.Username)
+ } else {
+ // This might hint that the username was
+ // invalid.
+ err = fmt.Errorf("%s (with username %q; password was never requested by PAM service)", err, opts.Username)
+ }
+ return arvados.APIClientAuthorization{}, httpserver.ErrorWithStatus(err, http.StatusUnauthorized)
+ }
+ if errorMessage != "" {
+ return arvados.APIClientAuthorization{}, httpserver.ErrorWithStatus(errors.New(errorMessage), http.StatusUnauthorized)
+ }
+ user, err := tx.GetItem(pam.User)
+ if err != nil {
+ return arvados.APIClientAuthorization{}, 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}})
+ resp, err := ctrl.RailsProxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+ // Send a fake ReturnTo value instead of the caller's
+ // opts.ReturnTo. We won't follow the resulting
+ // redirect target anyway.
+ ReturnTo: ",https://none.invalid",
+ AuthInfo: rpc.UserSessionAuthInfo{
+ Username: user,
+ Email: email,
+ },
+ })
+ if err != nil {
+ return arvados.APIClientAuthorization{}, err
+ }
+ target, err := url.Parse(resp.RedirectLocation)
+ if err != nil {
+ return arvados.APIClientAuthorization{}, err
+ }
+ token := target.Query().Get("api_token")
+ return ctrl.RailsProxy.APIClientAuthorizationCurrent(auth.NewContext(ctx, auth.NewCredentials(token)), arvados.GetOptions{})
+}
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Skip this slow test unless invoked as "go test -tags docker".
+// +build docker
+
+package localdb
+
+import (
+ "os"
+ "os/exec"
+
+ check "gopkg.in/check.v1"
+)
+
+func (s *PamSuite) TestLoginLDAPViaPAM(c *check.C) {
+ cmd := exec.Command("bash", "login_pam_docker_test.sh")
+ cmd.Stdout = os.Stderr
+ cmd.Stderr = os.Stderr
+ err := cmd.Run()
+ c.Check(err, check.IsNil)
+}
--- /dev/null
+#!/bin/bash
+
+# This script demonstrates using LDAP for Arvados user authentication.
+#
+# It configures pam_ldap(5) and arvados controller in a docker
+# container, with pam_ldap configured to authenticate against an
+# OpenLDAP server in a second docker container.
+#
+# After adding a "foo" user entry, it uses curl to check that the
+# Arvados controller's login endpoint accepts the "foo" account
+# username/password and rejects invalid credentials.
+#
+# It is intended to be run inside .../build/run-tests.sh (in
+# interactive mode: "test lib/controller/localdb -tags=docker
+# -check.f=LDAP -check.vv"). It assumes ARVADOS_TEST_API_HOST points
+# to a RailsAPI server and the desired version of arvados-server is
+# installed in $GOPATH/bin.
+
+set -e -o pipefail
+
+debug=/dev/null
+if [[ -n ${ARVADOS_DEBUG} ]]; then
+ debug=/dev/stderr
+ set -x
+fi
+
+hostname="$(hostname)"
+tmpdir="$(mktemp -d)"
+cleanup() {
+ trap - ERR
+ rm -r ${tmpdir}
+ for h in ${ldapctr} ${ctrlctr}; do
+ if [[ -n ${h} ]]; then
+ docker kill ${h}
+ fi
+ done
+}
+trap cleanup ERR
+
+if [[ -z "$(docker image ls -q osixia/openldap:1.3.0)" ]]; then
+ echo >&2 "Pulling docker image for ldap server"
+ docker pull osixia/openldap:1.3.0
+fi
+
+ldapctr=ldap-${RANDOM}
+echo >&2 "Starting ldap server in docker container ${ldapctr}"
+docker run --rm --detach \
+ -p 389 -p 636 \
+ --name=${ldapctr} \
+ osixia/openldap:1.3.0
+docker logs --follow ${ldapctr} 2>$debug >$debug &
+ldaphostport=$(docker port ${ldapctr} 389/tcp)
+ldapport=${ldaphostport##*:}
+ldapurl="ldap://${hostname}:${ldapport}"
+passwordhash="$(docker exec -i ${ldapctr} slappasswd -s "secret")"
+
+# These are the default admin credentials for osixia/openldap:1.3.0
+adminuser=admin
+adminpassword=admin
+
+cat >"${tmpdir}/zzzzz.yml" <<EOF
+Clusters:
+ zzzzz:
+ PostgreSQL:
+ Connection:
+ client_encoding: utf8
+ host: ${hostname}
+ dbname: arvados_test
+ user: arvados
+ password: insecure_arvados_test
+ ManagementToken: e687950a23c3a9bceec28c6223a06c79
+ SystemRootToken: systemusertesttoken1234567890aoeuidhtnsqjkxbmwvzpy
+ API:
+ RequestTimeout: 30s
+ TLS:
+ Insecure: true
+ Collections:
+ BlobSigningKey: zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc
+ TrustAllContent: true
+ ForwardSlashNameSubstitution: /
+ Services:
+ RailsAPI:
+ InternalURLs:
+ "https://${hostname}:${ARVADOS_TEST_API_HOST##*:}/": {}
+ Controller:
+ ExternalURL: http://0.0.0.0:9999/
+ InternalURLs:
+ "http://0.0.0.0:9999/": {}
+ Login:
+ PAM: true
+ # Without this magic PAMDefaultEmailDomain, inserted users would
+ # prevent subsequent database/reset from working (see
+ # database_controller.rb).
+ PAMDefaultEmailDomain: example.com
+ SystemLogs:
+ LogLevel: debug
+EOF
+
+cat >"${tmpdir}/pam_ldap.conf" <<EOF
+base dc=example,dc=org
+ldap_version 3
+uri ${ldapurl}
+pam_password crypt
+binddn cn=${adminuser},dc=example,dc=org
+bindpw ${adminpassword}
+EOF
+
+cat >"${tmpdir}/add_example_user.ldif" <<EOF
+dn: cn=bar,dc=example,dc=org
+objectClass: posixGroup
+objectClass: top
+cn: bar
+gidNumber: 11111
+description: "Example group 'bar'"
+
+dn: uid=foo,dc=example,dc=org
+uid: foo
+cn: foo
+givenName: Foo
+sn: Bar
+mail: foobar@example.org
+objectClass: inetOrgPerson
+objectClass: posixAccount
+objectClass: top
+objectClass: shadowAccount
+shadowMax: 180
+shadowMin: 1
+shadowWarning: 7
+shadowLastChange: 10701
+loginShell: /bin/bash
+uidNumber: 11111
+gidNumber: 11111
+homeDirectory: /home/foo
+userPassword: ${passwordhash}
+EOF
+
+echo >&2 "Adding example user entry user=foo pass=secret (retrying until server comes up)"
+docker run --rm --entrypoint= \
+ -v "${tmpdir}/add_example_user.ldif":/add_example_user.ldif:ro \
+ osixia/openldap:1.3.0 \
+ bash -c "for f in \$(seq 1 5); do if ldapadd -H '${ldapurl}' -D 'cn=${adminuser},dc=example,dc=org' -w '${adminpassword}' -f /add_example_user.ldif; then exit 0; else sleep 2; fi; done; echo 'failed to add user entry'; exit 1"
+
+echo >&2 "Building arvados controller binary to run in container"
+go build -o "${tmpdir}" ../../../cmd/arvados-server
+
+ctrlctr=ctrl-${RANDOM}
+echo >&2 "Starting arvados controller in docker container ${ctrlctr}"
+docker run --detach --rm --name=${ctrlctr} \
+ -p 9999 \
+ -v "${tmpdir}/pam_ldap.conf":/etc/pam_ldap.conf:ro \
+ -v "${tmpdir}/arvados-server":/bin/arvados-server:ro \
+ -v "${tmpdir}/zzzzz.yml":/etc/arvados/config.yml:ro \
+ -v $(realpath "${PWD}/../../.."):/arvados:ro \
+ debian:10 \
+ bash -c "apt update && DEBIAN_FRONTEND=noninteractive apt install -y ldap-utils libpam-ldap && pam-auth-update --package /usr/share/pam-configs/ldap && arvados-server controller"
+docker logs --follow ${ctrlctr} 2>$debug >$debug &
+ctrlhostport=$(docker port ${ctrlctr} 9999/tcp)
+
+echo >&2 "Waiting for arvados controller to come up..."
+for f in $(seq 1 20); do
+ if curl -s "http://${ctrlhostport}/arvados/v1/config" >/dev/null; then
+ break
+ else
+ sleep 1
+ fi
+ echo -n >&2 .
+done
+echo >&2
+echo >&2 "Arvados controller is up at http://${ctrlhostport}"
+
+check_contains() {
+ resp="${1}"
+ str="${2}"
+ if ! echo "${resp}" | fgrep -q "${str}"; then
+ echo >&2 "${resp}"
+ echo >&2 "FAIL: expected in response, but not found: ${str@Q}"
+ return 1
+ fi
+}
+
+echo >&2 "Testing authentication failure"
+resp="$(curl -s --include -d username=foo -d password=nosecret "http://${ctrlhostport}/arvados/v1/users/authenticate" | tee $debug)"
+check_contains "${resp}" "HTTP/1.1 401"
+check_contains "${resp}" '{"errors":["PAM: Authentication failure (with username \"foo\" and password)"]}'
+
+echo >&2 "Testing authentication success"
+resp="$(curl -s --include -d username=foo -d password=secret "http://${ctrlhostport}/arvados/v1/users/authenticate" | tee $debug)"
+check_contains "${resp}" "HTTP/1.1 200"
+check_contains "${resp}" '"api_token":"'
+check_contains "${resp}" '"scopes":["all"]'
+check_contains "${resp}" '"uuid":"zzzzz-gj3su-'
+
+cleanup
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+ "context"
+ "io/ioutil"
+ "net/http"
+ "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.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+ Username: "bogususername",
+ Password: "boguspassword",
+ })
+ c.Check(err, check.ErrorMatches, `PAM: Authentication failure \(with username "bogususername" and password\)`)
+ hs, ok := err.(interface{ HTTPStatus() int })
+ if c.Check(ok, check.Equals, true) {
+ c.Check(hs.HTTPStatus(), check.Equals, http.StatusUnauthorized)
+ }
+ c.Check(resp.APIToken, 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.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+ Username: u,
+ Password: p,
+ })
+ c.Check(err, check.IsNil)
+ c.Check(resp.APIToken, check.Not(check.Equals), "")
+ c.Check(resp.UUID, check.Matches, `zzzzz-gj3su-.*`)
+ c.Check(resp.Scopes, check.DeepEquals, []string{"all"})
+
+ authinfo := getCallbackAuthInfo(c, s.railsSpy)
+ c.Check(authinfo.Email, check.Equals, u+"@"+s.cluster.Login.PAMDefaultEmailDomain)
+ c.Check(authinfo.AlternateEmails, check.DeepEquals, []string(nil))
+}
return rtr.fed.UserDelete(ctx, *opts.(*arvados.DeleteOptions))
},
},
+ {
+ arvados.EndpointUserAuthenticate,
+ func() interface{} { return &arvados.UserAuthenticateOptions{} },
+ func(ctx context.Context, opts interface{}) (interface{}, error) {
+ return rtr.fed.UserAuthenticate(ctx, *opts.(*arvados.UserAuthenticateOptions))
+ },
+ },
} {
rtr.addRoute(route.endpoint, route.defaultOpts, route.exec)
}
default:
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, POST, PATCH, DELETE")
- w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
+ w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Http-Method-Override")
w.Header().Set("Access-Control-Max-Age", "86486400")
}
if r.Method == "OPTIONS" {
return
}
- r.ParseForm()
- if m := r.FormValue("_method"); m != "" {
- r2 := *r
- r = &r2
- r.Method = m
- } else if m = r.Header.Get("X-Http-Method-Override"); m != "" {
- r2 := *r
- r = &r2
- r.Method = m
+ if r.Method == "POST" {
+ r.ParseForm()
+ if m := r.FormValue("_method"); m != "" {
+ r2 := *r
+ r = &r2
+ r.Method = m
+ } else if m = r.Header.Get("X-Http-Method-Override"); m != "" {
+ r2 := *r
+ r = &r2
+ r.Method = m
+ }
}
rtr.mux.ServeHTTP(w, r)
}
}
func (conn *Conn) UserBatchUpdate(ctx context.Context, options arvados.UserBatchUpdateOptions) (arvados.UserList, error) {
- ep := arvados.APIEndpoint{Method: "PATCH", Path: "arvados/v1/users/batch_update"}
+ ep := arvados.EndpointUserBatchUpdate
var resp arvados.UserList
err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
return resp, err
}
+
+func (conn *Conn) UserAuthenticate(ctx context.Context, options arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
+ ep := arvados.EndpointUserAuthenticate
+ var resp arvados.APIClientAuthorization
+ err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+ return resp, err
+}
EndpointUserUnsetup = APIEndpoint{"POST", "arvados/v1/users/{uuid}/unsetup", ""}
EndpointUserUpdate = APIEndpoint{"PATCH", "arvados/v1/users/{uuid}", "user"}
EndpointUserUpdateUUID = APIEndpoint{"POST", "arvados/v1/users/{uuid}/update_uuid", ""}
- EndpointUserBatchUpdate = APIEndpoint{"PATCH", "arvados/v1/users/batch", ""}
+ EndpointUserBatchUpdate = APIEndpoint{"PATCH", "arvados/v1/users/batch_update", ""}
+ EndpointUserAuthenticate = APIEndpoint{"POST", "arvados/v1/users/authenticate", ""}
EndpointAPIClientAuthorizationCurrent = APIEndpoint{"GET", "arvados/v1/api_client_authorizations/current", ""}
)
State string `json:"state,omitempty"` // OAuth2 callback state
}
+type UserAuthenticateOptions struct {
+ Username string `json:"username,omitempty"` // PAM username
+ Password string `json:"password,omitempty"` // PAM password
+}
+
type LogoutOptions struct {
ReturnTo string `json:"return_to"` // Redirect to this URL after logging out
}
UserList(ctx context.Context, options ListOptions) (UserList, error)
UserDelete(ctx context.Context, options DeleteOptions) (User, error)
UserBatchUpdate(context.Context, UserBatchUpdateOptions) (UserList, error)
+ UserAuthenticate(ctx context.Context, options UserAuthenticateOptions) (APIClientAuthorization, error)
APIClientAuthorizationCurrent(ctx context.Context, options GetOptions) (APIClientAuthorization, 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
}
GoogleClientID string
GoogleClientSecret string
GoogleAlternateEmailAddresses bool
+ PAM bool
+ PAMService string
+ PAMDefaultEmailDomain string
ProviderAppID string
ProviderAppSecret string
LoginCluster string
)
type LoginResponse struct {
- RedirectLocation string
- HTML bytes.Buffer
+ RedirectLocation string `json:"redirect_location,omitempty"`
+ HTML bytes.Buffer `json:"-"`
}
func (resp LoginResponse) ServeHTTP(w http.ResponseWriter, req *http.Request) {
}
type LogoutResponse struct {
- RedirectLocation string
+ RedirectLocation string `json:"redirect_location,omitempty"`
}
func (resp LogoutResponse) ServeHTTP(w http.ResponseWriter, req *http.Request) {
as.appendCall(as.UserBatchUpdate, ctx, options)
return arvados.UserList{}, as.Error
}
+func (as *APIStub) UserAuthenticate(ctx context.Context, options arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
+ as.appendCall(as.UserAuthenticate, ctx, options)
+ return arvados.APIClientAuthorization{}, as.Error
+}
func (as *APIStub) APIClientAuthorizationCurrent(ctx context.Context, options arvados.GetOptions) (arvados.APIClientAuthorization, error) {
as.appendCall(as.APIClientAuthorizationCurrent, ctx, options)
return arvados.APIClientAuthorization{}, as.Error
RUN apt-get update && \
apt-get -yq --no-install-recommends -o Acquire::Retries=6 install \
postgresql-9.6 postgresql-contrib-9.6 git build-essential runit curl libpq-dev \
- libcurl4-openssl-dev libssl1.0-dev zlib1g-dev libpcre3-dev \
+ libcurl4-openssl-dev libssl1.0-dev zlib1g-dev libpcre3-dev libpam-dev \
openssh-server python-setuptools netcat-traditional \
python-epydoc graphviz bzip2 less sudo virtualenv \
libpython-dev fuse libfuse-dev python-pip python-yaml \