go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/encryptedcookies/internal/fakecookies/fakecookies.go (about) 1 // Copyright 2021 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package fakecookies implements a cookie-based fake authentication method. 16 // 17 // It is used during the development instead of real encrypted cookies. It is 18 // absolutely insecure and must not be used in any real server. 19 package fakecookies 20 21 import ( 22 "context" 23 "encoding/json" 24 "fmt" 25 "html/template" 26 "io" 27 "net/http" 28 "net/url" 29 "sync" 30 31 "golang.org/x/oauth2" 32 33 "go.chromium.org/luci/auth/identity" 34 "go.chromium.org/luci/common/errors" 35 "go.chromium.org/luci/common/logging" 36 "go.chromium.org/luci/common/retry/transient" 37 38 "go.chromium.org/luci/server/auth" 39 "go.chromium.org/luci/server/encryptedcookies/internal" 40 "go.chromium.org/luci/server/router" 41 ) 42 43 // AuthMethod is an auth.Method implementation that uses fake cookies. 44 type AuthMethod struct { 45 // LimitCookieExposure, if set, makes the fake cookie behave the same way as 46 // when this option is used with production cookies. 47 // 48 // See the module documentation. 49 LimitCookieExposure bool 50 // ExposedStateEndpoint is a URL path of the state endpoint, if any. 51 ExposedStateEndpoint string 52 53 m sync.Mutex 54 serverUser *auth.User // see serverUserInfo 55 serverUserInit bool // true if already initialized (can still be nil) 56 } 57 58 var _ interface { 59 auth.Method 60 auth.UsersAPI 61 auth.HasHandlers 62 auth.HasStateEndpoint 63 } = (*AuthMethod)(nil) 64 65 const ( 66 loginURL = "/auth/openid/login" 67 logoutURL = "/auth/openid/logout" 68 defaultPictureURL = "/auth/openid/profile.svg" 69 70 cookieName = "FAKE_LUCI_DEV_AUTH_COOKIE" 71 ) 72 73 // InstallHandlers installs HTTP handlers used in the login protocol. 74 // 75 // Implements auth.HasHandlers. 76 func (m *AuthMethod) InstallHandlers(r *router.Router, base router.MiddlewareChain) { 77 r.GET(loginURL, base, m.loginHandlerGET) 78 r.POST(loginURL, base, m.loginHandlerPOST) 79 r.GET(logoutURL, base, m.logoutHandler) 80 r.GET(defaultPictureURL, base, m.pictureHandler) 81 } 82 83 // Authenticate authenticates the request. 84 // 85 // Implements auth.Method. 86 func (m *AuthMethod) Authenticate(ctx context.Context, r auth.RequestMetadata) (*auth.User, auth.Session, error) { 87 cookie, _ := r.Cookie(cookieName) 88 if cookie == nil { 89 return nil, nil, nil // the method is not applicable, skip it 90 } 91 92 email, err := decodeFakeCookie(cookie.Value) 93 if err != nil { 94 logging.Warningf(ctx, "Skipping %s: %s", cookieName, err) 95 return nil, nil, nil 96 } 97 ident, err := identity.MakeIdentity("user:" + email) 98 if err != nil { 99 logging.Warningf(ctx, "Skipping %s: %s", cookieName, err) 100 return nil, nil, nil 101 } 102 103 user := &auth.User{ 104 Identity: ident, 105 Email: email, 106 Name: "Some User", 107 Picture: defaultPictureURL, 108 } 109 110 // If the local developer logs in using their email, we can actually produce 111 // real auth tokens (since the server runs under this account too). We can 112 // also try to extract the real profile information. Not a big deal if it is 113 // not available. It is not essential, just adds more "realism" when it is 114 // present. 115 if email == serverEmail(ctx) { 116 switch serverUser, err := m.serverUserInfo(ctx); { 117 case err != nil: 118 return nil, nil, errors.Annotate(err, "transient error getting server's user info").Tag(transient.Tag).Err() 119 case serverUser != nil: 120 user = serverUser 121 } 122 return user, serverSelfSession{}, nil 123 } 124 125 // If the fake session user is not matching server's email, use a fake profile 126 // and install an erroring session that asks the caller to log in as 127 // the developer. We can't generate real tokens for fake users. 128 return user, erroringSession{ 129 err: fmt.Errorf( 130 "session-bound auth tokens are available only when logging in "+ 131 "with the account used by the local dev server itself: %s", email, 132 ), 133 }, nil 134 } 135 136 // LoginURL returns a URL that, when visited, prompts the user to sign in, 137 // then redirects the user to the URL specified by dest. 138 // 139 // Implements auth.UsersAPI. 140 func (m *AuthMethod) LoginURL(ctx context.Context, dest string) (string, error) { 141 return internal.MakeRedirectURL(loginURL, dest) 142 } 143 144 // LogoutURL returns a URL that, when visited, signs the user out, 145 // then redirects the user to the URL specified by dest. 146 // 147 // Implements auth.UsersAPI. 148 func (m *AuthMethod) LogoutURL(ctx context.Context, dest string) (string, error) { 149 return internal.MakeRedirectURL(logoutURL, dest) 150 } 151 152 // StateEndpointURL returns an URL that serves the authentication state. 153 // 154 // Implements auth.HasStateEndpoint. 155 func (m *AuthMethod) StateEndpointURL(ctx context.Context) (string, error) { 156 if m.ExposedStateEndpoint != "" { 157 return m.ExposedStateEndpoint, nil 158 } 159 return "", auth.ErrNoStateEndpoint 160 } 161 162 // IsFakeCookiesSession returns true if the given auth.Session was produced by 163 // a fake cookies auth method. 164 func IsFakeCookiesSession(s auth.Session) bool { 165 switch s.(type) { 166 case serverSelfSession, erroringSession: 167 return true 168 default: 169 return false 170 } 171 } 172 173 //////////////////////////////////////////////////////////////////////////////// 174 175 var loginPageTmpl = template.Must(template.New("login").Parse(`<!DOCTYPE html> 176 <html lang="en"> 177 <head> 178 <title>Dev Mode Fake Login</title> 179 <style> 180 body { 181 font-family: "Roboto", sans-serif; 182 } 183 .container { 184 width: 440px; 185 padding-top: 50px; 186 margin: auto; 187 } 188 .form { 189 position: relative; 190 max-width: 440px; 191 padding: 45px; 192 margin: 0 auto 100px; 193 background: #ffffff; 194 text-align: center; 195 box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24); 196 } 197 .form input { 198 width: 100%; 199 padding: 15px; 200 margin: 0 0 15px; 201 background: #f2f2f2; 202 outline: 0; 203 border: 0; 204 box-sizing: border-box; 205 font-size: 14px; 206 } 207 .form button { 208 width: 100%; 209 padding: 15px; 210 outline: 0; 211 border: 0; 212 background: #404040; 213 color: #ffffff; 214 font-size: 14px; 215 cursor: pointer; 216 } 217 .form button:hover, .form button:active, .form button:focus { 218 background: #212121; 219 } 220 </style> 221 </head> 222 <body> 223 <div class="container"> 224 <div class="form"> 225 <form method="POST"> 226 <input type="text" placeholder="EMAIL" name="email" value="{{.Email}}"/> 227 <button>LOGIN</button> 228 </form> 229 </div> 230 </div> 231 </body> 232 </html>`)) 233 234 const profilePictureSVG = `<svg xmlns="http://www.w3.org/2000/svg" height="96px" width="96px" viewBox="0 0 24 24" fill="#455A64"> 235 <path d="M0 0h24v24H0V0z" fill="none"/> 236 <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm6.36 14.83c-1.43-1.74-4.9-2.33-6.36-2.33s-4.93.59-6.36 2.33C4.62 15.49 4 13.82 4 12c0-4.41 3.59-8 8-8s8 3.59 8 8c0 1.82-.62 3.49-1.64 4.83zM12 6c-1.94 0-3.5 1.56-3.5 3.5S10.06 13 12 13s3.5-1.56 3.5-3.5S13.94 6 12 6z"/> 237 </svg>` 238 239 // encodeFakeCookie prepares a cookie value that contains the given email. 240 func encodeFakeCookie(email string) string { 241 return (url.Values{"email": {email}}).Encode() 242 } 243 244 // decodeFakeCookies is reverse of encodeFakeCookie. 245 func decodeFakeCookie(val string) (email string, err error) { 246 v, err := url.ParseQuery(val) 247 if err != nil { 248 return "", err 249 } 250 return v.Get("email"), nil 251 } 252 253 // serverEmail returns the email the server runs as or "". 254 // 255 // In most cases the local dev server runs under the developer account. 256 func serverEmail(ctx context.Context) string { 257 if s := auth.GetSigner(ctx); s != nil { 258 if info, _ := s.ServiceInfo(ctx); info != nil { 259 return info.ServiceAccountName 260 } 261 } 262 return "" 263 } 264 265 // handler adapts `cb(...)` to match router.Handler. 266 func handler(ctx *router.Context, cb func(ctx context.Context, r *http.Request, rw http.ResponseWriter) error) { 267 if err := cb(ctx.Request.Context(), ctx.Request, ctx.Writer); err != nil { 268 http.Error(ctx.Writer, err.Error(), http.StatusInternalServerError) 269 } 270 } 271 272 // loginHandlerGET initiates the login flow. 273 func (m *AuthMethod) loginHandlerGET(ctx *router.Context) { 274 handler(ctx, func(ctx context.Context, r *http.Request, rw http.ResponseWriter) error { 275 if _, err := internal.NormalizeURL(r.URL.Query().Get("r")); err != nil { 276 return errors.Annotate(err, "bad redirect URI").Err() 277 } 278 email := serverEmail(ctx) 279 if email == "" { 280 email = "someone@example.com" 281 } 282 return loginPageTmpl.Execute(rw, map[string]string{"Email": email}) 283 }) 284 } 285 286 // loginHandlerPOST completes the login flow. 287 func (m *AuthMethod) loginHandlerPOST(ctx *router.Context) { 288 handler(ctx, func(ctx context.Context, r *http.Request, rw http.ResponseWriter) error { 289 dest, err := internal.NormalizeURL(r.URL.Query().Get("r")) 290 if err != nil { 291 return errors.Annotate(err, "bad redirect URI").Err() 292 } 293 email := r.FormValue("email") 294 if _, err := identity.MakeIdentity("user:" + email); err != nil { 295 return errors.Annotate(err, "bad email").Err() 296 } 297 298 var curPath, prevPath string 299 var sameSite http.SameSite 300 if m.LimitCookieExposure { 301 curPath = internal.LimitedCookiePath 302 prevPath = internal.UnlimitedCookiePath 303 sameSite = http.SameSiteStrictMode 304 } else { 305 curPath = internal.UnlimitedCookiePath 306 prevPath = internal.LimitedCookiePath 307 sameSite = 0 // use browser's default 308 } 309 310 http.SetCookie(rw, &http.Cookie{ 311 Name: cookieName, 312 Value: encodeFakeCookie(email), 313 Path: curPath, 314 SameSite: sameSite, 315 HttpOnly: true, 316 Secure: false, 317 MaxAge: 60 * 60 * 24 * 14, // 2 weeks 318 }) 319 internal.RemoveCookie(rw, r, cookieName, prevPath) 320 321 http.Redirect(rw, r, dest, http.StatusFound) 322 return nil 323 }) 324 } 325 326 // logoutHandler closes the session. 327 func (m *AuthMethod) logoutHandler(ctx *router.Context) { 328 handler(ctx, func(ctx context.Context, r *http.Request, rw http.ResponseWriter) error { 329 dest, err := internal.NormalizeURL(r.URL.Query().Get("r")) 330 if err != nil { 331 return errors.Annotate(err, "bad redirect URI").Err() 332 } 333 internal.RemoveCookie(rw, r, cookieName, internal.UnlimitedCookiePath) 334 internal.RemoveCookie(rw, r, cookieName, internal.LimitedCookiePath) 335 http.Redirect(rw, r, dest, http.StatusFound) 336 return nil 337 }) 338 } 339 340 // pictureHandler returns hardcoded SVG user profile picture. 341 func (m *AuthMethod) pictureHandler(ctx *router.Context) { 342 ctx.Writer.Header().Set("Content-Type", "image/svg+xml") 343 ctx.Writer.Header().Set("Cache-Control", "public, max-age=86400") 344 ctx.Writer.Write([]byte(profilePictureSVG)) 345 } 346 347 // serverUserInfo grabs *auth.User info based on server's own credentials. 348 // 349 // We use Google ID provider's /userinfo endpoint and access tokens. Note that 350 // we can't extract the profile information from the ID token since it may not 351 // be there anymore (if the token was refreshed already). 352 // 353 // Returns (nil, nil) if the user info is not available for some reason (e.g. 354 // when running the server under a service account). All errors should be 355 // considered transient. 356 func (m *AuthMethod) serverUserInfo(ctx context.Context) (*auth.User, error) { 357 m.m.Lock() 358 defer m.m.Unlock() 359 if m.serverUserInit { 360 return m.serverUser, nil 361 } 362 363 // See the comment in serverSelfSession.AccessToken regarding scopes. 364 tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(auth.CloudOAuthScopes...)) 365 if err != nil { 366 return nil, err 367 } 368 369 req, _ := http.NewRequest("GET", "https://openidconnect.googleapis.com/v1/userinfo", nil) 370 resp, err := (&http.Client{Transport: tr}).Do(req.WithContext(ctx)) 371 if err != nil { 372 return nil, err 373 } 374 defer resp.Body.Close() 375 376 body, err := io.ReadAll(resp.Body) 377 if err != nil { 378 return nil, err 379 } 380 381 if resp.StatusCode >= 500 { 382 return nil, errors.Reason("HTTP %d: %q", resp.StatusCode, body).Err() 383 } 384 385 if resp.StatusCode != 200 { 386 logging.Warningf(ctx, "When fetching server's own user info: HTTP %d, body %q", resp.StatusCode, body) 387 m.serverUserInit = true // we are done, no user info available 388 return nil, nil 389 } 390 391 var claims struct { 392 Email string `json:"email"` 393 Name string `json:"name"` 394 Picture string `json:"picture"` 395 } 396 if err := json.Unmarshal(body, &claims); err != nil { 397 return nil, errors.Annotate(err, "failed to deserialize userinfo endpoint response").Err() 398 } 399 400 m.serverUserInit = true 401 m.serverUser = &auth.User{ 402 Identity: identity.Identity("user:" + claims.Email), 403 Email: claims.Email, 404 Name: claims.Name, 405 Picture: claims.Picture, 406 } 407 return m.serverUser, nil 408 } 409 410 //////////////////////////////////////////////////////////////////////////////// 411 412 // serverSelfSession implements auth.Session by using server's own credentials. 413 // 414 // This is useful only when the session user matches the account the server 415 // is running as. This can happen only locally in the dev mode. 416 type serverSelfSession struct{} 417 418 func (serverSelfSession) AccessToken(ctx context.Context) (*oauth2.Token, error) { 419 // Strictly speaking we need only userinfo.email scope, but its refresh token 420 // might not be present locally. But a token with CloudOAuthScopes (which 421 // includes the userinfo.email scope) is guaranteed to be present, since 422 // the server checks for it when it starts. 423 ts, err := auth.GetTokenSource( 424 ctx, 425 auth.AsSelf, 426 auth.WithScopes(auth.CloudOAuthScopes...), 427 ) 428 if err != nil { 429 return nil, err 430 } 431 return ts.Token() 432 } 433 434 func (serverSelfSession) IDToken(ctx context.Context) (*oauth2.Token, error) { 435 // In a real scenario ID token audience always matches the OAuth client ID 436 // used during the login. We use some similarly looking fake. Note that this 437 // fake is ignored when running locally using a token established with 438 // `luci-auth login` (there's no way to substitute audiences of such local 439 // tokens). 440 ts, err := auth.GetTokenSource( 441 ctx, 442 auth.AsSelf, 443 auth.WithIDTokenAudience("fake-client-id.apps.example.com"), 444 ) 445 if err != nil { 446 return nil, err 447 } 448 return ts.Token() 449 } 450 451 // erroringSession returns the given error from all methods. 452 type erroringSession struct { 453 err error 454 } 455 456 func (s erroringSession) AccessToken(ctx context.Context) (*oauth2.Token, error) { 457 return nil, s.err 458 } 459 460 func (s erroringSession) IDToken(ctx context.Context) (*oauth2.Token, error) { 461 return nil, s.err 462 }