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  }