blob: a8475edc74ca743cdc7e9ba04df39ac930853ea5 [file] [log] [blame]
// Copyright 2015 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package openid
import (
"context"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/retry/transient"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/router"
)
// Note: this file is a part of deprecated CookieAuthMethod implementation.
// These are installed into a HTTP router by CookieAuthMethod.InstallHandlers.
const (
loginURL = "/auth/openid/login"
logoutURL = "/auth/openid/logout"
callbackURL = "/auth/openid/callback"
)
// errBadDestinationURL is returned by normalizeURL on errors.
var errBadDestinationURL = errors.New("openid: dest URL in LoginURL or LogoutURL must be relative")
// CookieAuthMethod implements auth.Method and auth.UsersAPI and can be used as
// one of authentication method in auth.Authenticator. It is using OpenID for
// login flow, stores session ID in cookies, and session itself in supplied
// SessionStore.
//
// It requires some routes to be added to the router. Use exact same instance
// of CookieAuthMethod in auth.Authenticator and when adding routes via
// InstallHandlers.
//
// DEPRECATED. Do not use.
type CookieAuthMethod struct {
// SessionStore keeps user sessions in some permanent storage. Must be set,
// otherwise all methods return ErrNotConfigured.
SessionStore auth.SessionStore
// Insecure is true to allow http:// URLs and non-https cookies. Useful for
// local development.
Insecure bool
// IncompatibleCookies is a list of cookies to remove when setting or clearing
// session cookie. It is useful to get rid of GAE cookies when OpenID cookies
// are being used. Having both is very confusing.
IncompatibleCookies []string
}
// Make sure all extra interfaces are implemented.
var _ interface {
auth.Method
auth.UsersAPI
auth.Warmable
auth.HasHandlers
} = (*CookieAuthMethod)(nil)
// InstallHandlers installs HTTP handlers used in OpenID protocol. Must be
// installed in server HTTP router for OpenID authentication flow to work.
//
// Implements auth.HasHandlers.
func (m *CookieAuthMethod) InstallHandlers(r *router.Router, base router.MiddlewareChain) {
r.GET(loginURL, base, m.loginHandler)
r.GET(logoutURL, base, m.logoutHandler)
r.GET(callbackURL, base, m.callbackHandler)
}
// Warmup prepares local caches. It's optional.
//
// Implements auth.Warmable.
func (m *CookieAuthMethod) Warmup(c context.Context) (err error) {
cfg, err := fetchCachedSettings(c)
if err != nil {
return
}
if cfg.DiscoveryURL != "" {
_, err = fetchDiscoveryDoc(c, cfg.DiscoveryURL)
} else {
logging.Infof(c, "Skipping OpenID warmup, not configured")
}
return
}
// Authenticate extracts peer's identity from the incoming request. It is part
// of auth.Method interface.
func (m *CookieAuthMethod) Authenticate(c context.Context, r *http.Request) (*auth.User, error) {
if m.SessionStore == nil {
return nil, ErrNotConfigured
}
// Grab session ID from the cookie.
sid, err := decodeSessionCookie(c, r)
if err != nil {
return nil, err
}
if sid == "" {
return nil, nil
}
// Grab session (with user information) from the store.
session, err := m.SessionStore.GetSession(c, sid)
if err != nil {
return nil, err
}
if session == nil {
(logging.Fields{"sid": sid}).Warningf(c, "The session cookie references unknown session")
return nil, nil
}
(logging.Fields{
"sid": sid,
"email": session.User.Email,
}).Debugf(c, "Fetched the session")
return &session.User, nil
}
// LoginURL returns a URL that, when visited, prompts the user to sign in,
// then redirects the user to the URL specified by dest. It is part of
// auth.UsersAPI interface.
func (m *CookieAuthMethod) LoginURL(c context.Context, dest string) (string, error) {
if m.SessionStore == nil {
return "", ErrNotConfigured
}
return makeRedirectURL(loginURL, dest)
}
// LogoutURL returns a URL that, when visited, signs the user out,
// then redirects the user to the URL specified by dest. It is part of
// auth.UsersAPI interface.
func (m *CookieAuthMethod) LogoutURL(c context.Context, dest string) (string, error) {
if m.SessionStore == nil {
return "", ErrNotConfigured
}
return makeRedirectURL(logoutURL, dest)
}
////
// loginHandler initiates login flow by redirecting user to OpenID login page.
func (m *CookieAuthMethod) loginHandler(ctx *router.Context) {
c, rw, r := ctx.Context, ctx.Writer, ctx.Request
dest, err := normalizeURL(r.URL.Query().Get("r"))
if err != nil {
replyError(c, rw, err, "Bad redirect URI (%q) - %s", dest, err)
return
}
cfg, err := fetchCachedSettings(c)
if err != nil {
replyError(c, rw, err, "Can't load OpenID settings - %s", err)
return
}
// `state` will be propagated by OpenID backend and will eventually show up
// in callback URI handler. See callbackHandler.
state := map[string]string{
"dest_url": dest,
"host_url": r.Host,
}
authURI, err := authenticationURI(c, cfg, state)
if err != nil {
replyError(c, rw, err, "Can't generate authentication URI - %s", err)
return
}
http.Redirect(rw, r, authURI, http.StatusFound)
}
// logoutHandler nukes active session and redirect back to destination URL.
func (m *CookieAuthMethod) logoutHandler(ctx *router.Context) {
c, rw, r := ctx.Context, ctx.Writer, ctx.Request
dest, err := normalizeURL(r.URL.Query().Get("r"))
if err != nil {
replyError(c, rw, err, "Bad redirect URI (%q) - %s", dest, err)
return
}
// Close a session if there's one.
sid, err := decodeSessionCookie(c, r)
if err != nil {
replyError(c, rw, err, "Error when decoding session cookie - %s", err)
return
}
if sid != "" {
(logging.Fields{"sid": sid}).Infof(c, "Closing the session")
if err = m.SessionStore.CloseSession(c, sid); err != nil {
replyError(c, rw, err, "Error when closing the session - %s", err)
return
}
}
// Nuke all session cookies to get to a completely clean state.
removeCookie(rw, r, sessionCookieName)
m.removeIncompatibleCookies(rw, r)
// Redirect to the final destination.
logging.Infof(c, "Redirecting to %s", dest)
http.Redirect(rw, r, dest, http.StatusFound)
}
// callbackHandler handles redirect from OpenID backend. Parameters contain
// authorization code that can be exchanged for user profile.
func (m *CookieAuthMethod) callbackHandler(ctx *router.Context) {
c, rw, r := ctx.Context, ctx.Writer, ctx.Request
// This code path is hit when user clicks "Deny" on consent page.
q := r.URL.Query()
errorMsg := q.Get("error")
if errorMsg != "" {
replyError(c, rw, errors.New("login error"), "OpenID login error: %s", errorMsg)
return
}
// Validate inputs.
code := q.Get("code")
if code == "" {
replyError(c, rw, errors.New("login error"), "Missing 'code' parameter")
return
}
stateTok := q.Get("state")
if stateTok == "" {
replyError(c, rw, errors.New("login error"), "Missing 'state' parameter")
return
}
state, err := validateStateToken(c, stateTok)
if err != nil {
replyError(c, rw, err, "Failed to validate 'state' token")
return
}
// Revalidate "dest_url". It was already validate in loginHandler when
// generating state token, but just in case.
dest, err := normalizeURL(state["dest_url"])
if err != nil {
replyError(c, rw, err, "Bad redirect URI (%q) - %s", dest, err)
return
}
// Callback URI is hardcoded in OAuth2 client config and must always point
// to default version on GAE. Yet we want to support logging to non-default
// versions that have different hostnames. Do some redirect dance here to pass
// control to required version if necessary (so that it can set cookie on
// non-default version domain). Same handler with same params, just with
// different hostname. For most common case of signing in into default version
// this code path is not triggered.
if state["host_url"] != r.Host {
// There's no Scheme in r.URL. Append one, otherwise url.String() returns
// relative (broken) URL. And replace the hostname with desired one.
url := *r.URL
if m.Insecure {
url.Scheme = "http"
} else {
url.Scheme = "https"
}
url.Host = state["host_url"]
logging.Warningf(c, "Redirecting to callback URI on another host %q", url.Host)
http.Redirect(rw, r, url.String(), http.StatusFound)
return
}
// Use authorization code to grab user profile.
cfg, err := fetchCachedSettings(c)
if err != nil {
replyError(c, rw, err, "Can't load OpenID settings - %s", err)
return
}
uid, user, err := handleAuthorizationCode(c, cfg, code)
if err != nil {
replyError(c, rw, err, "Error when fetching user profile - %s", err)
return
}
// Grab previous session from the cookie to close it once new one is created.
prevSid, err := decodeSessionCookie(c, r)
if err != nil {
replyError(c, rw, err, "Error when decoding session cookie - %s", err)
return
}
// Create session in the session store.
expTime := clock.Now(c).Add(sessionCookieToken.Expiration)
sid, err := m.SessionStore.OpenSession(c, uid, user, expTime)
if err != nil {
replyError(c, rw, err, "Error when creating the session - %s", err)
return
}
(logging.Fields{"sid": sid}).Infof(c, "Opened a new session")
// Kill previous session now that new one is successfully created.
if prevSid != "" {
(logging.Fields{"sid": prevSid}).Infof(c, "Closing the previous session")
if err = m.SessionStore.CloseSession(c, prevSid); err != nil {
replyError(c, rw, err, "Error when closing the session - %s", err)
return
}
}
// Set the cookies.
cookie, err := makeSessionCookie(c, sid, !m.Insecure)
if err != nil {
replyError(c, rw, err, "Can't make session cookie - %s", err)
return
}
http.SetCookie(rw, cookie)
m.removeIncompatibleCookies(rw, r)
// Redirect to the final destination page.
logging.Infof(c, "Redirecting to %s", dest)
http.Redirect(rw, r, dest, http.StatusFound)
}
// removeIncompatibleCookies removes cookies specified by m.IncompatibleCookies.
func (m *CookieAuthMethod) removeIncompatibleCookies(rw http.ResponseWriter, r *http.Request) {
for _, cookie := range m.IncompatibleCookies {
removeCookie(rw, r, cookie)
}
}
////
// normalizeURL verifies URL is parsable and that it is relative.
func normalizeURL(dest string) (string, error) {
u, err := url.Parse(dest)
if err != nil {
return "", err
}
// Note: '//host/path' is a location on a server named 'host'.
if u.IsAbs() || !strings.HasPrefix(u.Path, "/") || strings.HasPrefix(u.Path, "//") {
return "", errBadDestinationURL
}
// path.Clean removes trailing slash. It matters for URLs though. Keep it.
keepSlash := strings.HasSuffix(u.Path, "/")
u.Path = path.Clean(u.Path)
if !strings.HasSuffix(u.Path, "/") && keepSlash {
u.Path += "/"
}
if !strings.HasPrefix(u.Path, "/") {
return "", errBadDestinationURL
}
return u.String(), nil
}
// makeRedirectURL is used to generate login and logout URLs.
func makeRedirectURL(base, dest string) (string, error) {
dest, err := normalizeURL(dest)
if err != nil {
return "", err
}
v := url.Values{}
v.Set("r", dest)
return base + "?" + v.Encode(), nil
}
// removeCookie sets a cookie to past expiration date so that browser can remove
// it. Also replaced value with junk, in case browser decides to ignore
// expiration time.
func removeCookie(rw http.ResponseWriter, r *http.Request, cookie string) {
if prev, err := r.Cookie(cookie); err == nil {
cpy := *prev
cpy.Value = "deleted"
cpy.Path = "/"
cpy.MaxAge = -1
cpy.Expires = time.Unix(1, 0)
http.SetCookie(rw, &cpy)
}
}
// replyError logs the error and replies with HTTP 500 (on transient errors) or
// HTTP 400 on fatal errors (that can happen only on bad requests).
func replyError(c context.Context, rw http.ResponseWriter, err error, msg string, args ...interface{}) {
code := http.StatusBadRequest
if transient.Tag.In(err) {
code = http.StatusInternalServerError
}
msg = fmt.Sprintf(msg, args...)
logging.Errorf(c, "HTTP %d: %s", code, msg)
http.Error(rw, msg, code)
}