15881: Test ldap login with fake ldap server.
[arvados.git] / lib / controller / localdb / login_ldap.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package localdb
6
7 import (
8         "context"
9         "crypto/tls"
10         "errors"
11         "fmt"
12         "net"
13         "net/http"
14         "strings"
15
16         "git.arvados.org/arvados.git/lib/controller/rpc"
17         "git.arvados.org/arvados.git/sdk/go/arvados"
18         "git.arvados.org/arvados.git/sdk/go/ctxlog"
19         "git.arvados.org/arvados.git/sdk/go/httpserver"
20         "github.com/go-ldap/ldap"
21 )
22
23 type ldapLoginController struct {
24         Cluster    *arvados.Cluster
25         RailsProxy *railsProxy
26 }
27
28 func (ctrl *ldapLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
29         return noopLogout(ctrl.Cluster, opts)
30 }
31
32 func (ctrl *ldapLoginController) Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error) {
33         return arvados.LoginResponse{}, errors.New("interactive login is not available")
34 }
35
36 func (ctrl *ldapLoginController) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
37         log := ctxlog.FromContext(ctx)
38         conf := ctrl.Cluster.Login.LDAP
39         errFailed := httpserver.ErrorWithStatus(fmt.Errorf("LDAP: Authentication failure (with username %q and password)", opts.Username), http.StatusUnauthorized)
40
41         if opts.Password == "" {
42                 log.WithField("username", opts.Username).Error("refusing to authenticate with empty password")
43                 return arvados.APIClientAuthorization{}, errFailed
44         }
45
46         log = log.WithField("URL", conf.URL.String())
47         l, err := ldap.DialURL(conf.URL.String())
48         if err != nil {
49                 log.WithError(err).Error("ldap connection failed")
50                 return arvados.APIClientAuthorization{}, err
51         }
52         defer l.Close()
53
54         if conf.StartTLS {
55                 var tlsconfig tls.Config
56                 if conf.InsecureTLS {
57                         tlsconfig.InsecureSkipVerify = true
58                 } else {
59                         if host, _, err := net.SplitHostPort(conf.URL.Host); err != nil {
60                                 // Assume SplitHostPort error means
61                                 // port was not specified
62                                 tlsconfig.ServerName = conf.URL.Host
63                         } else {
64                                 tlsconfig.ServerName = host
65                         }
66                 }
67                 err = l.StartTLS(&tlsconfig)
68                 if err != nil {
69                         log.WithError(err).Error("ldap starttls failed")
70                         return arvados.APIClientAuthorization{}, err
71                 }
72         }
73
74         username := opts.Username
75         if at := strings.Index(username, "@"); at >= 0 {
76                 if conf.StripDomain == "*" || strings.ToLower(conf.StripDomain) == strings.ToLower(username[at+1:]) {
77                         username = username[:at]
78                 }
79         }
80         if conf.AppendDomain != "" && !strings.Contains(username, "@") {
81                 username = username + "@" + conf.AppendDomain
82         }
83
84         if conf.SearchBindUser != "" {
85                 err = l.Bind(conf.SearchBindUser, conf.SearchBindPassword)
86                 if err != nil {
87                         log.WithError(err).WithField("user", conf.SearchBindUser).Error("ldap authentication failed")
88                         return arvados.APIClientAuthorization{}, err
89                 }
90         }
91
92         if conf.SearchAttribute == "" {
93                 return arvados.APIClientAuthorization{}, errors.New("config error: must provide SearchAttribute")
94         }
95
96         search := fmt.Sprintf("(%s=%s)", ldap.EscapeFilter(conf.SearchAttribute), ldap.EscapeFilter(username))
97         if conf.SearchFilters != "" {
98                 search = fmt.Sprintf("(&%s%s)", conf.SearchFilters, search)
99         }
100         log = log.WithField("search", search)
101         req := ldap.NewSearchRequest(
102                 conf.SearchBase,
103                 ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false,
104                 search,
105                 []string{"DN", "givenName", "SN", conf.EmailAttribute, conf.UsernameAttribute},
106                 nil)
107         resp, err := l.Search(req)
108         if ldap.IsErrorWithCode(err, ldap.LDAPResultNoResultsReturned) ||
109                 ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) ||
110                 (err == nil && len(resp.Entries) == 0) {
111                 log.WithError(err).Info("ldap lookup returned no results")
112                 return arvados.APIClientAuthorization{}, errFailed
113         } else if err != nil {
114                 log.WithError(err).Error("ldap lookup failed")
115                 return arvados.APIClientAuthorization{}, err
116         }
117         userdn := resp.Entries[0].DN
118         if userdn == "" {
119                 log.Warn("refusing to authenticate with empty dn")
120                 return arvados.APIClientAuthorization{}, errFailed
121         }
122         log = log.WithField("DN", userdn)
123
124         attrs := map[string]string{}
125         for _, attr := range resp.Entries[0].Attributes {
126                 if attr == nil || len(attr.Values) == 0 {
127                         continue
128                 }
129                 attrs[strings.ToLower(attr.Name)] = attr.Values[0]
130         }
131         log.WithField("attrs", attrs).Debug("ldap search succeeded")
132
133         // Now that we have the DN, try authenticating.
134         err = l.Bind(userdn, opts.Password)
135         if err != nil {
136                 log.WithError(err).Info("ldap user authentication failed")
137                 return arvados.APIClientAuthorization{}, errFailed
138         }
139         log.Debug("ldap authentication succeeded")
140
141         email := attrs[strings.ToLower(conf.EmailAttribute)]
142         if email == "" {
143                 log.Errorf("ldap returned no email address in %q attribute", conf.EmailAttribute)
144                 return arvados.APIClientAuthorization{}, errors.New("authentication succeeded but ldap returned no email address")
145         }
146
147         return createAPIClientAuthorization(ctx, ctrl.RailsProxy, ctrl.Cluster.SystemRootToken, rpc.UserSessionAuthInfo{
148                 Email:     email,
149                 FirstName: attrs["givenname"],
150                 LastName:  attrs["sn"],
151                 Username:  attrs[strings.ToLower(conf.UsernameAttribute)],
152         })
153 }