19973: Merge branch 'main'
[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 func validateLoginRedirectTarget(cluster *arvados.Cluster, returnTo string) error {
168         u, err := url.Parse(returnTo)
169         if err != nil {
170                 return err
171         }
172         u, err = u.Parse("/")
173         if err != nil {
174                 return err
175         }
176         if u.Port() == "80" && u.Scheme == "http" {
177                 u.Host = u.Hostname()
178         } else if u.Port() == "443" && u.Scheme == "https" {
179                 u.Host = u.Hostname()
180         }
181         if _, ok := cluster.Login.TrustedClients[arvados.URL(*u)]; ok {
182                 return nil
183         }
184         if u.String() == cluster.Services.Workbench1.ExternalURL.String() ||
185                 u.String() == cluster.Services.Workbench2.ExternalURL.String() {
186                 return nil
187         }
188         if cluster.Login.TrustPrivateNetworks {
189                 if u.Hostname() == "localhost" {
190                         return nil
191                 }
192                 if ip := net.ParseIP(u.Hostname()); len(ip) > 0 {
193                         for _, n := range privateNetworks {
194                                 if n.Contains(ip) {
195                                         return nil
196                                 }
197                         }
198                 }
199         }
200         return fmt.Errorf("requesting site is not listed in TrustedClients config")
201 }