go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/auth/deprecated/cookie_method.go (about) 1 // Copyright 2015 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 deprecated 16 17 import ( 18 "context" 19 "fmt" 20 "net/http" 21 "net/url" 22 "path" 23 "strings" 24 "time" 25 26 "go.chromium.org/luci/common/clock" 27 "go.chromium.org/luci/common/errors" 28 "go.chromium.org/luci/common/logging" 29 "go.chromium.org/luci/common/retry/transient" 30 31 "go.chromium.org/luci/server/auth" 32 "go.chromium.org/luci/server/auth/openid" 33 "go.chromium.org/luci/server/router" 34 ) 35 36 // Note: this file is a part of deprecated CookieAuthMethod implementation. 37 38 // These are installed into a HTTP router by CookieAuthMethod.InstallHandlers. 39 const ( 40 loginURL = "/auth/openid/login" 41 logoutURL = "/auth/openid/logout" 42 callbackURL = "/auth/openid/callback" 43 ) 44 45 // errBadDestinationURL is returned by normalizeURL on errors. 46 var errBadDestinationURL = errors.New("openid: dest URL in LoginURL or LogoutURL must be relative") 47 48 // CookieAuthMethod implements auth.Method and auth.UsersAPI and can be used as 49 // one of authentication method in auth.Authenticator. It is using OpenID for 50 // login flow, stores session ID in cookies, and session itself in supplied 51 // SessionStore. 52 // 53 // It requires some routes to be added to the router. Use exact same instance 54 // of CookieAuthMethod in auth.Authenticator and when adding routes via 55 // InstallHandlers. 56 // 57 // DEPRECATED. Do not use. 58 type CookieAuthMethod struct { 59 // SessionStore keeps user sessions in some permanent storage. Must be set, 60 // otherwise all methods return ErrNotConfigured. 61 SessionStore SessionStore 62 63 // Insecure is true to allow http:// URLs and non-https cookies. Useful for 64 // local development. 65 Insecure bool 66 67 // IncompatibleCookies is a list of cookies to remove when setting or clearing 68 // session cookie. It is useful to get rid of GAE cookies when OpenID cookies 69 // are being used. Having both is very confusing. 70 IncompatibleCookies []string 71 } 72 73 // Make sure all extra interfaces are implemented. 74 var _ interface { 75 auth.Method 76 auth.UsersAPI 77 auth.Warmable 78 auth.HasHandlers 79 } = (*CookieAuthMethod)(nil) 80 81 // InstallHandlers installs HTTP handlers used in OpenID protocol. Must be 82 // installed in server HTTP router for OpenID authentication flow to work. 83 // 84 // Implements auth.HasHandlers. 85 func (m *CookieAuthMethod) InstallHandlers(r *router.Router, base router.MiddlewareChain) { 86 r.GET(loginURL, base, m.loginHandler) 87 r.GET(logoutURL, base, m.logoutHandler) 88 r.GET(callbackURL, base, m.callbackHandler) 89 } 90 91 // Warmup prepares local caches. It's optional. 92 // 93 // Implements auth.Warmable. 94 func (m *CookieAuthMethod) Warmup(ctx context.Context) (err error) { 95 cfg, err := FetchOpenIDSettings(ctx) 96 if err != nil { 97 return 98 } 99 if cfg.DiscoveryURL != "" { 100 _, err = openid.FetchDiscoveryDoc(ctx, cfg.DiscoveryURL) 101 } else { 102 logging.Infof(ctx, "Skipping OpenID warmup, not configured") 103 } 104 return 105 } 106 107 // Authenticate extracts peer's identity from the incoming request. It is part 108 // of auth.Method interface. 109 func (m *CookieAuthMethod) Authenticate(ctx context.Context, r auth.RequestMetadata) (*auth.User, auth.Session, error) { 110 if m.SessionStore == nil { 111 return nil, nil, ErrNotConfigured 112 } 113 114 // Grab session ID from the cookie. 115 sid, err := decodeSessionCookie(ctx, r) 116 if err != nil { 117 return nil, nil, err 118 } 119 if sid == "" { 120 return nil, nil, nil 121 } 122 123 // Grab session (with user information) from the store. 124 session, err := m.SessionStore.GetSession(ctx, sid) 125 if err != nil { 126 return nil, nil, err 127 } 128 if session == nil { 129 (logging.Fields{"sid": sid}).Warningf(ctx, "The session cookie references unknown session") 130 return nil, nil, nil 131 } 132 (logging.Fields{ 133 "sid": sid, 134 "email": session.User.Email, 135 }).Debugf(ctx, "Fetched the session") 136 return &session.User, nil, nil 137 } 138 139 // LoginURL returns a URL that, when visited, prompts the user to sign in, 140 // then redirects the user to the URL specified by dest. It is part of 141 // auth.UsersAPI interface. 142 func (m *CookieAuthMethod) LoginURL(ctx context.Context, dest string) (string, error) { 143 if m.SessionStore == nil { 144 return "", ErrNotConfigured 145 } 146 return makeRedirectURL(loginURL, dest) 147 } 148 149 // LogoutURL returns a URL that, when visited, signs the user out, 150 // then redirects the user to the URL specified by dest. It is part of 151 // auth.UsersAPI interface. 152 func (m *CookieAuthMethod) LogoutURL(ctx context.Context, dest string) (string, error) { 153 if m.SessionStore == nil { 154 return "", ErrNotConfigured 155 } 156 return makeRedirectURL(logoutURL, dest) 157 } 158 159 //// 160 161 // loginHandler initiates login flow by redirecting user to OpenID login page. 162 func (m *CookieAuthMethod) loginHandler(ctx *router.Context) { 163 c, rw, r := ctx.Request.Context(), ctx.Writer, ctx.Request 164 165 dest, err := normalizeURL(r.URL.Query().Get("r")) 166 if err != nil { 167 replyError(c, rw, err, "Bad redirect URI (%q) - %s", dest, err) 168 return 169 } 170 171 cfg, err := FetchOpenIDSettings(c) 172 if err != nil { 173 replyError(c, rw, err, "Can't load OpenID settings - %s", err) 174 return 175 } 176 177 // `state` will be propagated by OpenID backend and will eventually show up 178 // in callback URI handler. See callbackHandler. 179 state := map[string]string{ 180 "dest_url": dest, 181 "host_url": r.Host, 182 } 183 authURI, err := authenticationURI(c, cfg, state) 184 if err != nil { 185 replyError(c, rw, err, "Can't generate authentication URI - %s", err) 186 return 187 } 188 http.Redirect(rw, r, authURI, http.StatusFound) 189 } 190 191 // logoutHandler nukes active session and redirect back to destination URL. 192 func (m *CookieAuthMethod) logoutHandler(ctx *router.Context) { 193 c, rw, r := ctx.Request.Context(), ctx.Writer, ctx.Request 194 195 dest, err := normalizeURL(r.URL.Query().Get("r")) 196 if err != nil { 197 replyError(c, rw, err, "Bad redirect URI (%q) - %s", dest, err) 198 return 199 } 200 201 // Close a session if there's one. 202 sid, err := decodeSessionCookie(c, r) 203 if err != nil { 204 replyError(c, rw, err, "Error when decoding session cookie - %s", err) 205 return 206 } 207 if sid != "" { 208 (logging.Fields{"sid": sid}).Infof(c, "Closing the session") 209 if err = m.SessionStore.CloseSession(c, sid); err != nil { 210 replyError(c, rw, err, "Error when closing the session - %s", err) 211 return 212 } 213 } 214 215 // Nuke all session cookies to get to a completely clean state. 216 removeCookie(rw, r, sessionCookieName) 217 m.removeIncompatibleCookies(rw, r) 218 219 // Redirect to the final destination. 220 logging.Infof(c, "Redirecting to %s", dest) 221 http.Redirect(rw, r, dest, http.StatusFound) 222 } 223 224 // callbackHandler handles redirect from OpenID backend. Parameters contain 225 // authorization code that can be exchanged for user profile. 226 func (m *CookieAuthMethod) callbackHandler(ctx *router.Context) { 227 c, rw, r := ctx.Request.Context(), ctx.Writer, ctx.Request 228 229 // This code path is hit when user clicks "Deny" on consent page. 230 q := r.URL.Query() 231 errorMsg := q.Get("error") 232 if errorMsg != "" { 233 replyError(c, rw, errors.New("login error"), "OpenID login error: %s", errorMsg) 234 return 235 } 236 237 // Validate inputs. 238 code := q.Get("code") 239 if code == "" { 240 replyError(c, rw, errors.New("login error"), "Missing 'code' parameter") 241 return 242 } 243 stateTok := q.Get("state") 244 if stateTok == "" { 245 replyError(c, rw, errors.New("login error"), "Missing 'state' parameter") 246 return 247 } 248 state, err := validateStateToken(c, stateTok) 249 if err != nil { 250 replyError(c, rw, err, "Failed to validate 'state' token") 251 return 252 } 253 254 // Revalidate "dest_url". It was already validate in loginHandler when 255 // generating state token, but just in case. 256 dest, err := normalizeURL(state["dest_url"]) 257 if err != nil { 258 replyError(c, rw, err, "Bad redirect URI (%q) - %s", dest, err) 259 return 260 } 261 262 // Callback URI is hardcoded in OAuth2 client config and must always point 263 // to default version on GAE. Yet we want to support logging to non-default 264 // versions that have different hostnames. Do some redirect dance here to pass 265 // control to required version if necessary (so that it can set cookie on 266 // non-default version domain). Same handler with same params, just with 267 // different hostname. For most common case of signing in into default version 268 // this code path is not triggered. 269 if state["host_url"] != r.Host { 270 // There's no Scheme in r.URL. Append one, otherwise url.String() returns 271 // relative (broken) URL. And replace the hostname with desired one. 272 url := *r.URL 273 if m.Insecure { 274 url.Scheme = "http" 275 } else { 276 url.Scheme = "https" 277 } 278 url.Host = state["host_url"] 279 logging.Warningf(c, "Redirecting to callback URI on another host %q", url.Host) 280 http.Redirect(rw, r, url.String(), http.StatusFound) 281 return 282 } 283 284 // Use authorization code to grab user profile. 285 cfg, err := FetchOpenIDSettings(c) 286 if err != nil { 287 replyError(c, rw, err, "Can't load OpenID settings - %s", err) 288 return 289 } 290 uid, user, err := handleAuthorizationCode(c, cfg, code) 291 if err != nil { 292 replyError(c, rw, err, "Error when fetching user profile - %s", err) 293 return 294 } 295 296 // Grab previous session from the cookie to close it once new one is created. 297 prevSid, err := decodeSessionCookie(c, r) 298 if err != nil { 299 replyError(c, rw, err, "Error when decoding session cookie - %s", err) 300 return 301 } 302 303 // Create session in the session store. 304 expTime := clock.Now(c).Add(sessionCookieToken.Expiration) 305 sid, err := m.SessionStore.OpenSession(c, uid, user, expTime) 306 if err != nil { 307 replyError(c, rw, err, "Error when creating the session - %s", err) 308 return 309 } 310 (logging.Fields{"sid": sid}).Infof(c, "Opened a new session") 311 312 // Kill previous session now that new one is successfully created. 313 if prevSid != "" { 314 (logging.Fields{"sid": prevSid}).Infof(c, "Closing the previous session") 315 if err = m.SessionStore.CloseSession(c, prevSid); err != nil { 316 replyError(c, rw, err, "Error when closing the session - %s", err) 317 return 318 } 319 } 320 321 // Set the cookies. 322 cookie, err := makeSessionCookie(c, sid, !m.Insecure) 323 if err != nil { 324 replyError(c, rw, err, "Can't make session cookie - %s", err) 325 return 326 } 327 http.SetCookie(rw, cookie) 328 m.removeIncompatibleCookies(rw, r) 329 330 // Redirect to the final destination page. 331 logging.Infof(c, "Redirecting to %s", dest) 332 http.Redirect(rw, r, dest, http.StatusFound) 333 } 334 335 // removeIncompatibleCookies removes cookies specified by m.IncompatibleCookies. 336 func (m *CookieAuthMethod) removeIncompatibleCookies(rw http.ResponseWriter, r *http.Request) { 337 for _, cookie := range m.IncompatibleCookies { 338 removeCookie(rw, r, cookie) 339 } 340 } 341 342 //// 343 344 // normalizeURL verifies URL is parsable and that it is relative. 345 func normalizeURL(dest string) (string, error) { 346 u, err := url.Parse(dest) 347 if err != nil { 348 return "", err 349 } 350 // Note: '//host/path' is a location on a server named 'host'. 351 if u.IsAbs() || !strings.HasPrefix(u.Path, "/") || strings.HasPrefix(u.Path, "//") { 352 return "", errBadDestinationURL 353 } 354 // path.Clean removes trailing slash. It matters for URLs though. Keep it. 355 keepSlash := strings.HasSuffix(u.Path, "/") 356 u.Path = path.Clean(u.Path) 357 if !strings.HasSuffix(u.Path, "/") && keepSlash { 358 u.Path += "/" 359 } 360 if !strings.HasPrefix(u.Path, "/") { 361 return "", errBadDestinationURL 362 } 363 return u.String(), nil 364 } 365 366 // makeRedirectURL is used to generate login and logout URLs. 367 func makeRedirectURL(base, dest string) (string, error) { 368 dest, err := normalizeURL(dest) 369 if err != nil { 370 return "", err 371 } 372 v := url.Values{} 373 v.Set("r", dest) 374 return base + "?" + v.Encode(), nil 375 } 376 377 // removeCookie sets a cookie to past expiration date so that browser can remove 378 // it. Also replaced value with junk, in case browser decides to ignore 379 // expiration time. 380 func removeCookie(rw http.ResponseWriter, r *http.Request, cookie string) { 381 if prev, err := r.Cookie(cookie); err == nil { 382 cpy := *prev 383 cpy.Value = "deleted" 384 cpy.Path = "/" 385 cpy.MaxAge = -1 386 cpy.Expires = time.Unix(1, 0) 387 http.SetCookie(rw, &cpy) 388 } 389 } 390 391 // replyError logs the error and replies with HTTP 500 (on transient errors) or 392 // HTTP 400 on fatal errors (that can happen only on bad requests). 393 func replyError(ctx context.Context, rw http.ResponseWriter, err error, msg string, args ...any) { 394 code := http.StatusBadRequest 395 if transient.Tag.In(err) { 396 code = http.StatusInternalServerError 397 } 398 msg = fmt.Sprintf(msg, args...) 399 logging.Errorf(ctx, "HTTP %d: %s", code, msg) 400 http.Error(rw, msg, code) 401 }