15881: Add LDAP authentication option.
[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=%s))", conf.SearchFilters, ldap.EscapeFilter(conf.SearchAttribute), ldap.EscapeFilter(username))
97         log = log.WithField("search", search)
98         req := ldap.NewSearchRequest(
99                 conf.SearchBase,
100                 ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false,
101                 search,
102                 []string{"DN", "givenName", "SN", conf.EmailAttribute, conf.UsernameAttribute},
103                 nil)
104         resp, err := l.Search(req)
105         if ldap.IsErrorWithCode(err, ldap.LDAPResultNoResultsReturned) ||
106                 ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) ||
107                 (err == nil && len(resp.Entries) == 0) {
108                 log.WithError(err).Debug("ldap lookup returned no results")
109                 return arvados.APIClientAuthorization{}, errFailed
110         } else if err != nil {
111                 log.WithError(err).Error("ldap lookup failed")
112                 return arvados.APIClientAuthorization{}, err
113         }
114         userdn := resp.Entries[0].DN
115         if userdn == "" {
116                 log.Warn("refusing to authenticate with empty dn")
117                 return arvados.APIClientAuthorization{}, errFailed
118         }
119         log = log.WithField("DN", userdn)
120
121         attrs := map[string]string{}
122         for _, attr := range resp.Entries[0].Attributes {
123                 if attr == nil || len(attr.Values) == 0 {
124                         continue
125                 }
126                 attrs[strings.ToLower(attr.Name)] = attr.Values[0]
127         }
128         log.WithField("attrs", attrs).Debug("ldap search succeeded")
129
130         // Now that we have the DN, try authenticating.
131         err = l.Bind(userdn, opts.Password)
132         if err != nil {
133                 log.WithError(err).Warn("ldap user authentication failed")
134                 return arvados.APIClientAuthorization{}, errFailed
135         }
136         log.Debug("ldap authentication succeeded")
137
138         email := attrs[strings.ToLower(conf.EmailAttribute)]
139         if email == "" {
140                 log.Errorf("ldap returned no email address in %q attribute", conf.EmailAttribute)
141                 return arvados.APIClientAuthorization{}, errors.New("authentication succeeded but ldap returned no email address")
142         }
143
144         return createAPIClientAuthorization(ctx, ctrl.RailsProxy, ctrl.Cluster.SystemRootToken, rpc.UserSessionAuthInfo{
145                 Email:     email,
146                 FirstName: attrs["givenname"],
147                 LastName:  attrs["sn"],
148                 Username:  attrs[strings.ToLower(conf.UsernameAttribute)],
149         })
150 }