Merge branch '18790-log-client'
[arvados.git] / lib / controller / localdb / login.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         "database/sql"
10         "encoding/json"
11         "errors"
12         "fmt"
13         "net"
14         "net/http"
15         "net/url"
16         "strings"
17
18         "git.arvados.org/arvados.git/lib/controller/rpc"
19         "git.arvados.org/arvados.git/lib/ctrlctx"
20         "git.arvados.org/arvados.git/sdk/go/arvados"
21         "git.arvados.org/arvados.git/sdk/go/auth"
22         "git.arvados.org/arvados.git/sdk/go/httpserver"
23 )
24
25 type loginController interface {
26         Login(ctx context.Context, opts arvados.LoginOptions) (arvados.LoginResponse, error)
27         Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error)
28         UserAuthenticate(ctx context.Context, options arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error)
29 }
30
31 func chooseLoginController(cluster *arvados.Cluster, parent *Conn) loginController {
32         wantGoogle := cluster.Login.Google.Enable
33         wantOpenIDConnect := cluster.Login.OpenIDConnect.Enable
34         wantPAM := cluster.Login.PAM.Enable
35         wantLDAP := cluster.Login.LDAP.Enable
36         wantTest := cluster.Login.Test.Enable
37         wantLoginCluster := cluster.Login.LoginCluster != "" && cluster.Login.LoginCluster != cluster.ClusterID
38         switch {
39         case 1 != countTrue(wantGoogle, wantOpenIDConnect, wantPAM, wantLDAP, wantTest, wantLoginCluster):
40                 return errorLoginController{
41                         error: errors.New("configuration problem: exactly one of Login.Google, Login.OpenIDConnect, Login.PAM, Login.LDAP, Login.Test, or Login.LoginCluster must be set"),
42                 }
43         case wantGoogle:
44                 return &oidcLoginController{
45                         Cluster:            cluster,
46                         Parent:             parent,
47                         Issuer:             "https://accounts.google.com",
48                         ClientID:           cluster.Login.Google.ClientID,
49                         ClientSecret:       cluster.Login.Google.ClientSecret,
50                         AuthParams:         cluster.Login.Google.AuthenticationRequestParameters,
51                         UseGooglePeopleAPI: cluster.Login.Google.AlternateEmailAddresses,
52                         EmailClaim:         "email",
53                         EmailVerifiedClaim: "email_verified",
54                 }
55         case wantOpenIDConnect:
56                 return &oidcLoginController{
57                         Cluster:                cluster,
58                         Parent:                 parent,
59                         Issuer:                 cluster.Login.OpenIDConnect.Issuer,
60                         ClientID:               cluster.Login.OpenIDConnect.ClientID,
61                         ClientSecret:           cluster.Login.OpenIDConnect.ClientSecret,
62                         AuthParams:             cluster.Login.OpenIDConnect.AuthenticationRequestParameters,
63                         EmailClaim:             cluster.Login.OpenIDConnect.EmailClaim,
64                         EmailVerifiedClaim:     cluster.Login.OpenIDConnect.EmailVerifiedClaim,
65                         UsernameClaim:          cluster.Login.OpenIDConnect.UsernameClaim,
66                         AcceptAccessToken:      cluster.Login.OpenIDConnect.AcceptAccessToken,
67                         AcceptAccessTokenScope: cluster.Login.OpenIDConnect.AcceptAccessTokenScope,
68                 }
69         case wantPAM:
70                 return &pamLoginController{Cluster: cluster, Parent: parent}
71         case wantLDAP:
72                 return &ldapLoginController{Cluster: cluster, Parent: parent}
73         case wantTest:
74                 return &testLoginController{Cluster: cluster, Parent: parent}
75         case wantLoginCluster:
76                 return &federatedLoginController{Cluster: cluster}
77         default:
78                 return errorLoginController{
79                         error: errors.New("BUG: missing case in login controller setup switch"),
80                 }
81         }
82 }
83
84 func countTrue(vals ...bool) int {
85         n := 0
86         for _, val := range vals {
87                 if val {
88                         n++
89                 }
90         }
91         return n
92 }
93
94 type errorLoginController struct{ error }
95
96 func (ctrl errorLoginController) Login(context.Context, arvados.LoginOptions) (arvados.LoginResponse, error) {
97         return arvados.LoginResponse{}, ctrl.error
98 }
99 func (ctrl errorLoginController) Logout(context.Context, arvados.LogoutOptions) (arvados.LogoutResponse, error) {
100         return arvados.LogoutResponse{}, ctrl.error
101 }
102 func (ctrl errorLoginController) UserAuthenticate(context.Context, arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
103         return arvados.APIClientAuthorization{}, ctrl.error
104 }
105
106 type federatedLoginController struct {
107         Cluster *arvados.Cluster
108 }
109
110 func (ctrl federatedLoginController) Login(context.Context, arvados.LoginOptions) (arvados.LoginResponse, error) {
111         return arvados.LoginResponse{}, httpserver.ErrorWithStatus(errors.New("Should have been redirected to login cluster"), http.StatusBadRequest)
112 }
113 func (ctrl federatedLoginController) Logout(ctx context.Context, opts arvados.LogoutOptions) (arvados.LogoutResponse, error) {
114         return logout(ctx, ctrl.Cluster, opts)
115 }
116 func (ctrl federatedLoginController) UserAuthenticate(context.Context, arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
117         return arvados.APIClientAuthorization{}, httpserver.ErrorWithStatus(errors.New("username/password authentication is not available"), http.StatusBadRequest)
118 }
119
120 func (conn *Conn) CreateAPIClientAuthorization(ctx context.Context, rootToken string, authinfo rpc.UserSessionAuthInfo) (resp arvados.APIClientAuthorization, err error) {
121         if rootToken == "" {
122                 return arvados.APIClientAuthorization{}, errors.New("configuration error: empty SystemRootToken")
123         }
124         ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{rootToken}})
125         newsession, err := conn.railsProxy.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
126                 // Send a fake ReturnTo value instead of the caller's
127                 // opts.ReturnTo. We won't follow the resulting
128                 // redirect target anyway.
129                 ReturnTo: ",https://controller.api.client.invalid",
130                 AuthInfo: authinfo,
131         })
132         if err != nil {
133                 return
134         }
135         target, err := url.Parse(newsession.RedirectLocation)
136         if err != nil {
137                 return
138         }
139         token := target.Query().Get("api_token")
140         tx, err := ctrlctx.CurrentTx(ctx)
141         if err != nil {
142                 return
143         }
144         tokensecret := token
145         if strings.Contains(token, "/") {
146                 tokenparts := strings.Split(token, "/")
147                 if len(tokenparts) >= 3 {
148                         tokensecret = tokenparts[2]
149                 }
150         }
151         var exp sql.NullTime
152         var scopes []byte
153         err = tx.QueryRowxContext(ctx, "select uuid, api_token, expires_at, scopes from api_client_authorizations where api_token=$1", tokensecret).Scan(&resp.UUID, &resp.APIToken, &exp, &scopes)
154         if err != nil {
155                 return
156         }
157         resp.ExpiresAt = exp.Time
158         if len(scopes) > 0 {
159                 err = json.Unmarshal(scopes, &resp.Scopes)
160                 if err != nil {
161                         return resp, fmt.Errorf("unmarshal scopes: %s", err)
162                 }
163         }
164         return
165 }
166
167 var errUserinfoInRedirectTarget = errors.New("redirect target rejected because it contains userinfo")
168
169 func validateLoginRedirectTarget(cluster *arvados.Cluster, returnTo string) error {
170         u, err := url.Parse(returnTo)
171         if err != nil {
172                 return err
173         }
174         u, err = u.Parse("/")
175         if err != nil {
176                 return err
177         }
178         if u.User != nil {
179                 return errUserinfoInRedirectTarget
180         }
181         target := origin(*u)
182         for trusted := range cluster.Login.TrustedClients {
183                 trustedOrigin := origin(url.URL(trusted))
184                 if trustedOrigin == target {
185                         return nil
186                 }
187                 // If TrustedClients has https://*.bar.example, we
188                 // trust https://foo.bar.example. Note origin() has
189                 // already stripped the incoming Path, so we won't
190                 // accidentally trust
191                 // https://attacker.example/pwn.bar.example here. See
192                 // tests.
193                 if strings.HasPrefix(trustedOrigin, u.Scheme+"://*.") && strings.HasSuffix(target, trustedOrigin[len(u.Scheme)+4:]) {
194                         return nil
195                 }
196         }
197         if target == origin(url.URL(cluster.Services.Workbench1.ExternalURL)) ||
198                 target == origin(url.URL(cluster.Services.Workbench2.ExternalURL)) {
199                 return nil
200         }
201         if cluster.Login.TrustPrivateNetworks {
202                 if u.Hostname() == "localhost" {
203                         return nil
204                 }
205                 if ip := net.ParseIP(u.Hostname()); len(ip) > 0 {
206                         for _, n := range privateNetworks {
207                                 if n.Contains(ip) {
208                                         return nil
209                                 }
210                         }
211                 }
212         }
213         return fmt.Errorf("requesting site is not listed in TrustedClients config")
214 }
215
216 // origin returns the canonical origin of a URL, e.g.,
217 // origin("https://example:443/foo") returns "https://example/"
218 func origin(u url.URL) string {
219         origin := url.URL{
220                 Scheme: u.Scheme,
221                 Host:   u.Host,
222                 Path:   "/",
223         }
224         if origin.Port() == "80" && origin.Scheme == "http" {
225                 origin.Host = origin.Hostname()
226         } else if origin.Port() == "443" && origin.Scheme == "https" {
227                 origin.Host = origin.Hostname()
228         }
229         return origin.String()
230 }