blob: d097ecbd596b3b697178f9b46dbb14a1025a77c8 [file] [log] [blame]
// login handles logging in users.
package login
// Theory of operation.
//
// We use OAuth 2.0 handle authentication. We are essentially doing OpenID
// Connect, but vastly simplified since we are hardcoded to Google's endpoints.
//
// We do a simple OAuth 2.0 flow where the user is asked to grant permission to
// the 'email' scope. See https://developers.google.com/+/api/oauth#email for
// details on that scope. Note that you need to have the Google Plus API turned
// on in your project for this to work, but note that the 'email' scope will
// still work for people w/o Google Plus accounts.
//
// Now in theory once we are authorized and have an Access Token we could call
// https://developers.google.com/+/api/openidconnect/getOpenIdConnect and get the
// users email address. But here we can cheat, as we know it's Google and that for
// the 'email' scope an ID Token will be returned along with the Access Token.
// If we decode the ID Token we can get the email address out of that w/o the
// need for the second round trip. This is all clearly *ahem* explained here:
//
// https://developers.google.com/accounts/docs/OAuth2Login#exchangecode
//
// Once we get the users email address we put it in a cookie for later
// retrieval. The cookie value is validated using HMAC to stop spoofing.
//
// N.B. The cookiesaltkey metadata value must be set on the GCE instance.
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/gorilla/securecookie"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/metadata"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
const (
COOKIE_NAME = "sktoken"
SESSION_COOKIE_NAME = "sksession"
DEFAULT_COOKIE_SALT = "notverysecret"
// DEFAULT_REDIRECT_URL is the redirect URL to use if Init is called with DEFAULT_DOMAIN_WHITELIST.
DEFAULT_REDIRECT_URL = "https://skia.org/oauth2callback/"
// DEFAULT_DOMAIN_WHITELIST is a white list of domains we use frequently.
DEFAULT_DOMAIN_WHITELIST = "google.com chromium.org skia.org"
// DEFAULT_ADMIN_WHITELIST is the white list of users we consider admins when we can't retrieve the whitelist from metadata.
DEFAULT_ADMIN_WHITELIST = "benjaminwagner@google.com borenet@google.com jcgregorio@google.com kjlubick@google.com rmistry@google.com stephana@google.com"
// COOKIE_DOMAIN is the domain that are cookies attached to.
COOKIE_DOMAIN = "skia.org"
)
var (
// cookieSalt is some entropy for our encoders.
cookieSalt = ""
secureCookie *securecookie.SecureCookie = nil
// oauthConfig is the OAuth 2.0 client configuration.
oauthConfig = &oauth2.Config{
ClientID: "not-a-valid-client-id",
ClientSecret: "not-a-valid-client-secret",
Scopes: DEFAULT_SCOPE,
Endpoint: google.Endpoint,
RedirectURL: "http://localhost:8000/oauth2callback/",
}
// activeUserDomainWhiteList is the list of domains that are allowed to
// log in.
activeUserDomainWhiteList map[string]bool
// activeUserEmailWhiteList is the list of email addresses that are
// allowed to log in (even if the domain is not whitelisted).
activeUserEmailWhiteList map[string]bool
// activeAdminEmailWhiteList is the list of email addresses that are
// allowed to perform admin tasks.
activeAdminEmailWhiteList map[string]bool
// DEFAULT_SCOPE is the scope we request when logging in.
DEFAULT_SCOPE = []string{"email"}
)
// Session is encrypted and serialized and stored in a user's cookie.
type Session struct {
Email string
ID string
AuthScope string
Token *oauth2.Token
}
// SimpleInitMust initializes the login system for the default case, which uses
// DEFAULT_REDIRECT_URL in prod along with the DEFAULT_DOMAIN_WHITELIST and
// uses a localhost'port' redirect URL if 'local' is true.
//
// If an error occurs then the function fails fatally.
func SimpleInitMust(port string, local bool) {
redirectURL := fmt.Sprintf("http://localhost%s/oauth2callback/", port)
if !local {
redirectURL = DEFAULT_REDIRECT_URL
}
if err := Init(redirectURL, DEFAULT_DOMAIN_WHITELIST); err != nil {
sklog.Fatalf("Failed to initialize the login system: %s", err)
}
}
// Init must be called before any other login methods.
//
// The function first tries to load the cookie salt, client id, and client
// secret from GCE project level metadata. If that fails it looks for a
// "client_secret.json" file in the current directory to extract the client id
// and client secret from. If both of those fail then it returns an error.
//
// The authWhiteList is the space separated list of domains and email addresses
// that are allowed to log in.
func Init(redirectURL string, authWhiteList string) error {
cookieSalt, clientID, clientSecret := tryLoadingFromMetadata()
if clientID == "" {
b, err := ioutil.ReadFile("client_secret.json")
if err != nil {
return fmt.Errorf("Failed to read from metadata and from client_secret.json file: %s", err)
}
config, err := google.ConfigFromJSON(b)
if err != nil {
return fmt.Errorf("Failed to read from metadata and decode client_secret.json file: %s", err)
}
clientID = config.ClientID
clientSecret = config.ClientSecret
}
initLogin(clientID, clientSecret, redirectURL, cookieSalt, DEFAULT_SCOPE, authWhiteList)
return nil
}
// initLogin sets the params. It should only be called directly for testing purposes.
// Clients should use Init().
func initLogin(clientID, clientSecret, redirectURL, cookieSalt string, scopes []string, authWhiteList string) {
secureCookie = securecookie.New([]byte(cookieSalt), nil)
oauthConfig.ClientID = clientID
oauthConfig.ClientSecret = clientSecret
oauthConfig.RedirectURL = redirectURL
oauthConfig.Scopes = scopes
setActiveWhitelists(authWhiteList)
}
// LoginURL returns a URL that the user is to be directed to for login.
func LoginURL(w http.ResponseWriter, r *http.Request) string {
// Check for a session id, if not there then assign one, and add it to the redirect URL.
session, err := r.Cookie(SESSION_COOKIE_NAME)
state := ""
if err != nil || session.Value == "" {
state, err = util.GenerateID()
if err != nil {
sklog.Errorf("Failed to create a session token: %s", err)
return ""
}
cookie := &http.Cookie{
Name: SESSION_COOKIE_NAME,
Value: state,
Path: "/",
Domain: domainFromHost(r.Host),
HttpOnly: true,
Expires: time.Now().Add(365 * 24 * time.Hour),
}
http.SetCookie(w, cookie)
} else {
state = session.Value
}
redirect := r.Referer()
if redirect == "" {
// If we don't have a referrer then we need to construct the URL to
// bounce back to. This only works if r.Host is set correctly, which
// it should be as long as 'proxy_set_header Host $host;' is set for
// the nginx server config.
redirect = "https://" + r.Host + r.RequestURI
}
// Append the current URL to the state, in a way that's safe from tampering,
// so that we can use it on the rebound. So the state we pass in has the
// form:
//
// <sessionid>:<hash(salt + original url)>:<original url>
//
// Note that the sessionid and the hash are hex values and so won't contain
// any colons. To break this up when returned from the server just use
// strings.SplitN(s, ":", 3) which will ignore any colons found in the
// Referral URL.
//
// On the receiving side we need to recompute the hash and compare against
// the hash passed in, and only if they match should the redirect URL be
// trusted.
state = fmt.Sprintf("%s:%x:%s", state, sha256.Sum256([]byte(cookieSalt+redirect)), redirect)
// Only retrieve an online access token, i.e. no refresh token. And when we
// go through the approval flow again don't stop if they've already approved
// once, unless they have a valid token but aren't in the whitelist,
// in which case we want to use ApprovalForce so they get the chance
// to pick a different account to log in with.
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOnline}
s, err := getSession(r)
if err == nil && !inWhitelist(s.Email) {
opts = append(opts, oauth2.ApprovalForce)
} else {
opts = append(opts, oauth2.SetAuthURLParam("approval_prompt", "auto"))
}
return oauthConfig.AuthCodeURL(state, opts...)
}
func getSession(r *http.Request) (*Session, error) {
cookie, err := r.Cookie(COOKIE_NAME)
if err != nil {
return nil, err
}
var s Session
sklog.Infof("Cookie is: %v\n", cookie)
if err := secureCookie.Decode(COOKIE_NAME, cookie.Value, &s); err != nil {
return nil, err
}
if s.AuthScope != strings.Join(oauthConfig.Scopes, " ") {
return nil, fmt.Errorf("Stored auth scope differs from expected (%v vs %s)", oauthConfig.Scopes, s.AuthScope)
}
return &s, nil
}
// LoggedInAs returns the user's ID, i.e. their email address, if they are
// logged in, and "" if they are not logged in.
func LoggedInAs(r *http.Request) string {
s, err := getSession(r)
if err != nil {
return ""
}
if !inWhitelist(s.Email) {
return ""
}
return s.Email
}
// ID returns the user's ID, i.e. their opaque identifier, if they are
// logged in, and "" if they are not logged in.
func ID(r *http.Request) string {
s, err := getSession(r)
if err != nil {
return ""
}
return s.ID
}
// UserIdentifiers returns both the email and opaque user id of the logged in
// user, and will return two empty strings if they are not logged in.
func UserIdentifiers(r *http.Request) (string, string) {
s, err := getSession(r)
if err != nil {
return "", ""
}
return s.Email, s.ID
}
// IsGoogler determines whether the user is logged in with an @google.com account.
func IsGoogler(r *http.Request) bool {
return strings.HasSuffix(LoggedInAs(r), "@google.com")
}
// IsAdmin determines whether the user is logged in with an account on the admin
// whitelist. If true, user is allowed to perform admin tasks.
func IsAdmin(r *http.Request) bool {
return activeAdminEmailWhiteList[LoggedInAs(r)]
}
// A JSON Web Token can contain much info, such as 'iss' and 'sub'. We don't care about
// that, we only want one field which is 'email'.
//
// {
// "iss":"accounts.google.com",
// "sub":"110642259984599645813",
// "email":"jcgregorio@google.com",
// ...
// }
type decodedIDToken struct {
Email string `json:"email"`
ID string `json:"sub"`
}
// domainFromHost returns the value to use in the cookie Domain field based on
// the requests Host value.
func domainFromHost(fullhost string) string {
// Split host and port.
parts := strings.Split(fullhost, ":")
host := parts[0]
if host == "localhost" {
return host
}
return COOKIE_DOMAIN
}
// CookieFor creates an encoded Cookie for the given user id.
func CookieFor(value *Session, r *http.Request) (*http.Cookie, error) {
encoded, err := secureCookie.Encode(COOKIE_NAME, value)
if err != nil {
return nil, fmt.Errorf("Failed to encode cookie")
}
return &http.Cookie{
Name: COOKIE_NAME,
Value: encoded,
Path: "/",
Domain: domainFromHost(r.Host),
HttpOnly: true,
Expires: time.Now().Add(365 * 24 * time.Hour),
}, nil
}
func setSkIDCookieValue(w http.ResponseWriter, r *http.Request, value *Session) {
cookie, err := CookieFor(value, r)
if err != nil {
http.Error(w, fmt.Sprintf("%s", err), 500)
return
}
http.SetCookie(w, cookie)
}
// LogoutHandler logs the user out by overwriting the cookie with a blank email
// address.
//
// Note that this doesn't revoke the 'email' grant, so logging in later will
// still be fast. Users can always visit
//
// https://security.google.com/settings/security/permissions
//
// to revoke any grants they make.
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
sklog.Infof("LogoutHandler\n")
setSkIDCookieValue(w, r, &Session{})
http.Redirect(w, r, r.FormValue("redirect"), 302)
}
// OAuth2CallbackHandler must be attached at a handler that matches
// the callback URL registered in the APIs Console. In this case
// "/oauth2callback".
func OAuth2CallbackHandler(w http.ResponseWriter, r *http.Request) {
sklog.Infof("OAuth2CallbackHandler\n")
cookie, err := r.Cookie(SESSION_COOKIE_NAME)
if err != nil || cookie.Value == "" {
http.Error(w, "Invalid session state.", 500)
return
}
state := r.FormValue("state")
stateParts := strings.SplitN(state, ":", 3)
redirect := "/"
// If the state contains a redirect URL.
if len(stateParts) == 3 {
// state has this form: <sessionid>:<hash(salt + original url)>:<original url>
// See LoginURL for more details.
state = stateParts[0]
hash := stateParts[1]
url := stateParts[2]
expectedHash := fmt.Sprintf("%x", sha256.Sum256([]byte(cookieSalt+url)))
if hash == expectedHash {
redirect = url
} else {
sklog.Warning("Got an invalid redirect: %s != %s", hash, expectedHash)
}
}
if state != cookie.Value {
http.Error(w, "Session state doesn't match callback state.", 500)
return
}
code := r.FormValue("code")
sklog.Infof("Code: %s ", code[:5])
token, err := oauthConfig.Exchange(oauth2.NoContext, code)
if err != nil {
sklog.Errorf("Failed to authenticate: %s", err)
http.Error(w, "Failed to authenticate.", 500)
return
}
// idToken is a JSON Web Token. We only need to decode the token, we do not
// need to validate the token because it came to us over HTTPS directly from
// Google's servers.
idToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "No id_token returned.", 500)
return
}
// The id token is actually three base64 encoded parts that are "." separated.
segments := strings.Split(idToken, ".")
if len(segments) != 3 {
http.Error(w, "Invalid id_token.", 500)
return
}
// Now base64 decode the middle segment, which decodes to JSON.
padding := 4 - (len(segments[1]) % 4)
if padding == 4 {
padding = 0
}
middle := segments[1] + strings.Repeat("=", padding)
b, err := base64.URLEncoding.DecodeString(middle)
if err != nil {
sklog.Errorf("Failed to base64 decode middle part of token: %s From: %#v", middle, segments)
http.Error(w, "Failed to base64 decode id_token.", 500)
return
}
// Finally decode the JSON.
decoded := &decodedIDToken{}
if err := json.Unmarshal(b, decoded); err != nil {
sklog.Errorf("Failed to JSON decode token: %s", string(b))
http.Error(w, "Failed to JSON decode id_token.", 500)
return
}
email := strings.ToLower(decoded.Email)
parts := strings.Split(email, "@")
if len(parts) != 2 {
http.Error(w, "Invalid email address received.", 500)
return
}
if !inWhitelist(email) {
http.Error(w, "Accounts from your domain are not allowed or your email address is not white listed.", 500)
return
}
s := Session{
Email: email,
ID: decoded.ID,
AuthScope: strings.Join(oauthConfig.Scopes, " "),
Token: token,
}
setSkIDCookieValue(w, r, &s)
http.Redirect(w, r, redirect, 302)
}
// inWhitelist returns true if the given email address matches either the
// domain or the user whitelist.
func inWhitelist(email string) bool {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return false
}
if len(activeUserDomainWhiteList) > 0 && !activeUserDomainWhiteList[parts[1]] && !activeUserEmailWhiteList[email] {
return false
}
return true
}
// StatusHandler returns the login status of the user as JSON that looks like:
//
// {
// "Email": "fred@example.com",
// "ID": "12342...34324",
// "LoginURL": "https://..."
// "IsAGoogler": false,
// }
//
func StatusHandler(w http.ResponseWriter, r *http.Request) {
sklog.Infof("StatusHandler\n")
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
email, id := UserIdentifiers(r)
body := struct {
Email string
ID string
LoginURL string
IsAGoogler bool
}{
Email: email,
ID: id,
LoginURL: LoginURL(w, r),
IsAGoogler: IsGoogler(r),
}
if err := enc.Encode(body); err != nil {
sklog.Errorf("Failed to encode Login status to JSON: %s", err)
}
}
// GetHttpClient returns a http.Client which performs authenticated requests as
// the logged-in user.
func GetHttpClient(r *http.Request) *http.Client {
s, err := getSession(r)
if err != nil {
sklog.Errorf("Failed to get session state; falling back to default http client.")
return &http.Client{}
}
return oauthConfig.Client(oauth2.NoContext, s.Token)
}
// ForceAuth is middleware that enforces authentication
// before the wrapped handler is called. oauthCallbackPath is the
// URL path that the user is redirected to at the end of the auth flow.
func ForceAuth(h http.Handler, oauthCallbackPath string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userId := LoggedInAs(r)
if userId == "" {
// If this is not the oauth callback then redirect.
if !strings.HasPrefix(r.URL.Path, oauthCallbackPath) {
redirectUrl := LoginURL(w, r)
sklog.Infof("Redirect URL: %s", redirectUrl)
if redirectUrl == "" {
httputils.ReportError(w, r, fmt.Errorf("Unable to get redirect URL."), "Redirect to login failed:")
return
}
http.Redirect(w, r, redirectUrl, http.StatusTemporaryRedirect)
return
}
}
h.ServeHTTP(w, r)
})
}
func splitAuthWhiteList(whiteList string) (map[string]bool, map[string]bool) {
domains := map[string]bool{}
emails := map[string]bool{}
for _, entry := range strings.Fields(whiteList) {
trimmed := strings.ToLower(strings.TrimSpace(entry))
if strings.Contains(trimmed, "@") {
emails[trimmed] = true
} else {
domains[trimmed] = true
}
}
return domains, emails
}
// setActiveWhitelists initializes activeDomainWhiteList and
// activeEmailWhiteList from authWhiteList.
func setActiveWhitelists(authWhiteList string) {
activeUserDomainWhiteList, activeUserEmailWhiteList = splitAuthWhiteList(authWhiteList)
adminWhiteList := metadata.ProjectGetWithDefault(metadata.ADMIN_WHITE_LIST, DEFAULT_ADMIN_WHITELIST)
_, activeAdminEmailWhiteList = splitAuthWhiteList(adminWhiteList)
}
// tryLoadingFromMetadata tries to load the cookie salt, client id, and client
// secret from GCE project level metadata. If it fails then it returns the salt
// it was passed and the client id and secret are the empty string.
//
// Returns salt, clientID, clientSecret.
func tryLoadingFromMetadata() (string, string, string) {
cookieSalt, err := metadata.ProjectGet(metadata.COOKIESALT)
if err != nil {
return DEFAULT_COOKIE_SALT, "", ""
}
clientID, err := metadata.ProjectGet(metadata.CLIENT_ID)
if err != nil {
return DEFAULT_COOKIE_SALT, "", ""
}
clientSecret, err := metadata.ProjectGet(metadata.CLIENT_SECRET)
if err != nil {
return DEFAULT_COOKIE_SALT, "", ""
}
return cookieSalt, clientID, clientSecret
}