Merge branch '22285-architecture-page' refs #22285
[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         Parent  *Conn
26 }
27
28 func (ctrl *ldapLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
29         return logout(ctx, 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 conf.SearchAttribute == "" {
42                 return arvados.APIClientAuthorization{}, errors.New("config error: SearchAttribute is blank")
43         }
44         if opts.Password == "" {
45                 log.WithField("username", opts.Username).Error("refusing to authenticate with empty password")
46                 return arvados.APIClientAuthorization{}, errFailed
47         }
48
49         log = log.WithField("URL", conf.URL.String())
50         var l *ldap.Conn
51         var err error
52         if conf.URL.Scheme == "ldaps" {
53                 // ldap.DialURL does not currently allow us to control
54                 // tls.Config, so we need to figure out the port
55                 // ourselves and call DialTLS.
56                 host, port, err := net.SplitHostPort(conf.URL.Host)
57                 if err != nil {
58                         // Assume error means no port given
59                         host = conf.URL.Host
60                         port = ldap.DefaultLdapsPort
61                 }
62                 l, err = ldap.DialTLS("tcp", net.JoinHostPort(host, port), &tls.Config{
63                         ServerName: host,
64                         MinVersion: uint16(conf.MinTLSVersion),
65                 })
66         } else {
67                 l, err = ldap.DialURL(conf.URL.String())
68         }
69         if err != nil {
70                 log.WithError(err).Error("ldap connection failed")
71                 return arvados.APIClientAuthorization{}, err
72         }
73         defer l.Close()
74
75         if conf.StartTLS {
76                 var tlsconfig tls.Config
77                 tlsconfig.MinVersion = uint16(conf.MinTLSVersion)
78                 if conf.InsecureTLS {
79                         tlsconfig.InsecureSkipVerify = true
80                 } else {
81                         if host, _, err := net.SplitHostPort(conf.URL.Host); err != nil {
82                                 // Assume SplitHostPort error means
83                                 // port was not specified
84                                 tlsconfig.ServerName = conf.URL.Host
85                         } else {
86                                 tlsconfig.ServerName = host
87                         }
88                 }
89                 err = l.StartTLS(&tlsconfig)
90                 if err != nil {
91                         log.WithError(err).Error("ldap starttls failed")
92                         return arvados.APIClientAuthorization{}, err
93                 }
94         }
95
96         username := opts.Username
97         if at := strings.Index(username, "@"); at >= 0 {
98                 if conf.StripDomain == "*" || strings.ToLower(conf.StripDomain) == strings.ToLower(username[at+1:]) {
99                         username = username[:at]
100                 }
101         }
102         if conf.AppendDomain != "" && !strings.Contains(username, "@") {
103                 username = username + "@" + conf.AppendDomain
104         }
105
106         if conf.SearchBindUser != "" {
107                 err = l.Bind(conf.SearchBindUser, conf.SearchBindPassword)
108                 if err != nil {
109                         log.WithError(err).WithField("user", conf.SearchBindUser).Error("ldap authentication failed")
110                         return arvados.APIClientAuthorization{}, err
111                 }
112         }
113
114         search := fmt.Sprintf("(%s=%s)", ldap.EscapeFilter(conf.SearchAttribute), ldap.EscapeFilter(username))
115         if conf.SearchFilters != "" {
116                 search = fmt.Sprintf("(&%s%s)", conf.SearchFilters, search)
117         }
118         log = log.WithField("search", search)
119         req := ldap.NewSearchRequest(
120                 conf.SearchBase,
121                 ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false,
122                 search,
123                 []string{"DN", "givenName", "SN", conf.EmailAttribute, conf.UsernameAttribute},
124                 nil)
125         resp, err := l.Search(req)
126         if ldap.IsErrorWithCode(err, ldap.LDAPResultNoResultsReturned) ||
127                 ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) ||
128                 (err == nil && len(resp.Entries) == 0) {
129                 log.WithError(err).Info("ldap lookup returned no results")
130                 return arvados.APIClientAuthorization{}, errFailed
131         } else if err != nil {
132                 log.WithError(err).Error("ldap lookup failed")
133                 return arvados.APIClientAuthorization{}, err
134         }
135         userdn := resp.Entries[0].DN
136         if userdn == "" {
137                 log.Warn("refusing to authenticate with empty dn")
138                 return arvados.APIClientAuthorization{}, errFailed
139         }
140         log = log.WithField("DN", userdn)
141
142         attrs := map[string]string{}
143         for _, attr := range resp.Entries[0].Attributes {
144                 if attr == nil || len(attr.Values) == 0 {
145                         continue
146                 }
147                 attrs[strings.ToLower(attr.Name)] = attr.Values[0]
148         }
149         log.WithField("attrs", attrs).Debug("ldap search succeeded")
150
151         // Now that we have the DN, try authenticating.
152         err = l.Bind(userdn, opts.Password)
153         if err != nil {
154                 log.WithError(err).Info("ldap user authentication failed")
155                 return arvados.APIClientAuthorization{}, errFailed
156         }
157         log.Debug("ldap authentication succeeded")
158
159         email := attrs[strings.ToLower(conf.EmailAttribute)]
160         if email == "" {
161                 log.Errorf("ldap returned no email address in %q attribute", conf.EmailAttribute)
162                 return arvados.APIClientAuthorization{}, errors.New("authentication succeeded but ldap returned no email address")
163         }
164
165         return ctrl.Parent.CreateAPIClientAuthorization(ctx, ctrl.Cluster.SystemRootToken, rpc.UserSessionAuthInfo{
166                 Email:     email,
167                 FirstName: attrs["givenname"],
168                 LastName:  attrs["sn"],
169                 Username:  attrs[strings.ToLower(conf.UsernameAttribute)],
170         })
171 }