blob: 7ae475cef3ff0e1345274da033b6a274d28debaa [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"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/securecookie"
"go.skia.org/infra/go/allowed"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/metadata"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
oauth2_api "google.golang.org/api/oauth2/v2"
)
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_OAUTH2_CALLBACK is the default relative OAuth2 redirect URL.
DEFAULT_OAUTH2_CALLBACK = "/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_SKIA_ORG is the cookie domain for skia.org.
COOKIE_DOMAIN_SKIA_ORG = "skia.org"
// COOKIE_DOMAIN_SKIA_CORP is the cookie domain for skia*.corp.goog.
COOKIE_DOMAIN_SKIA_CORP = "corp.goog"
// LOGIN_CONFIG_FILE is the location of the login config when running in kubernetes.
LOGIN_CONFIG_FILE = "/etc/skia.org/login.json"
// DEFAULT_CLIENT_SECRET_FILE is the default path to the file used for OAuth2 login.
DEFAULT_CLIENT_SECRET_FILE = "client_secret.json"
)
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"}
// Auth groups which determine whether a given user has particular types
// of access. If nil, fall back on domain and individual email
// whitelist.
adminAllow allowed.Allow
editAllow allowed.Allow
viewAllow allowed.Allow
)
// 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)
}
}
// SimpleInitWithAllow initializes the login system for the default case (see
// docs for SimpleInitMust) and sets the admin, editor, and viewer lists. These
// may be nil, in which case we fall back on the default whitelists. For editors
// we default to denying access to everyone, and for viewers we default to
// allowing access to everyone.
func SimpleInitWithAllow(port string, local bool, admin, edit, view allowed.Allow) {
redirectURL := fmt.Sprintf("http://localhost%s/oauth2callback/", port)
if !local {
redirectURL = DEFAULT_REDIRECT_URL
}
InitWithAllow(redirectURL, admin, edit, view)
}
// InitWithAllow initializes the login system with the given redirect URL. Sets
// the admin, editor, and viewer lists as provided. These may be nil, in which
// case we fall back on the default whitelists. For editors we default to
// denying access to everyone, and for viewers we default to allowing access
// to everyone.
func InitWithAllow(redirectURL string, admin, edit, view allowed.Allow) {
adminAllow = admin
editAllow = edit
viewAllow = view
if err := Init(redirectURL, DEFAULT_DOMAIN_WHITELIST, ""); err != nil {
sklog.Fatalf("Failed to initialize the login system: %s", err)
}
RestrictAdmin = RestrictWithMessage(adminAllow, "User is not an admin")
RestrictEditor = RestrictWithMessage(editAllow, "User is not an editor")
RestrictViewer = RestrictWithMessage(viewAllow, "User is not a viewer")
}
// 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, clientSecretFile string) error {
cookieSalt, clientID, clientSecret := tryLoadingFromKnownLocations()
if clientID == "" {
if clientSecretFile == "" {
clientSecretFile = DEFAULT_CLIENT_SECRET_FILE
}
b, err := ioutil.ReadFile(clientSecretFile)
if err != nil {
return skerr.Fmt("Failed to read from metadata and from %s. Got error: %s", clientSecretFile, err)
}
config, err := google.ConfigFromJSON(b)
if err != nil {
return skerr.Fmt("Failed to read from metadata and decode %s. Got error: %s", clientSecretFile, err)
}
sklog.Infof("Successfully read client secret from %s", clientSecretFile)
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
if cookie != nil && len(cookie.String()) > 20 {
sklog.Infof("Cookie is: %s", cookie.String()[:20])
} else {
// This is likely nil or invalid, so no need to elide.
sklog.Infof("Cookie is: %v", 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 {
var email string
if s, err := getSession(r); err == nil {
email = s.Email
} else if e, err := ViaBearerToken(r); err == nil {
email = e
}
if inWhitelist(email) {
// TODO(stephana): Uncomment the following line when Debugf is different from Infof.
// sklog.Debugf("User %s is on the whitelist", email)
return email
}
// TODO(stephana): Uncomment the following when Debugf is different from Infof.
// if email != "" {
// sklog.Debugf("User %s is not on whitelist", email)
// }
return ""
}
// 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 {
email := LoggedInAs(r)
if adminAllow != nil {
return adminAllow.Member(email)
}
return activeAdminEmailWhiteList[email]
}
// IsEditor determines whether the user is logged in with an account on the
// editor whitelist. If true, user is allowed to perform edits. Defaults to
// false if no editor whitelist is provided.
func IsEditor(r *http.Request) bool {
email := LoggedInAs(r)
if editAllow != nil {
return editAllow.Member(email)
}
return false
}
// IsViewer determines whether the user is allowed to view this server. Defaults
// to true if no viewer whitelist is provided.
func IsViewer(r *http.Request) bool {
email := LoggedInAs(r)
if viewAllow != nil {
return viewAllow.Member(email)
}
return true
}
// 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 strings.HasPrefix(fullhost, "localhost") {
return host
} else if strings.HasSuffix(fullhost, "."+COOKIE_DOMAIN_SKIA_CORP) {
return COOKIE_DOMAIN_SKIA_CORP
} else if strings.HasSuffix(fullhost, "."+COOKIE_DOMAIN_SKIA_ORG) {
return COOKIE_DOMAIN_SKIA_ORG
} else {
sklog.Errorf("Unknown domain for host: %s; falling back to %s", fullhost, COOKIE_DOMAIN_SKIA_ORG)
return COOKIE_DOMAIN_SKIA_ORG
}
}
// 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")
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")
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.Warningf("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 viewAllow != nil {
return viewAllow.Member(email)
}
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,
// "IsViewer": true,
// "IsEditor": true,
// "IsAdmin: false
// }
//
func StatusHandler(w http.ResponseWriter, r *http.Request) {
sklog.Infof("StatusHandler")
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
IsAdmin bool
IsEditor bool
IsViewer bool
}{
Email: email,
ID: id,
LoginURL: LoginURL(w, r),
IsAGoogler: IsGoogler(r),
IsAdmin: IsAdmin(r),
IsEditor: IsEditor(r),
IsViewer: IsViewer(r),
}
sklog.Infof("Origin: %s", r.Header.Get("Origin"))
if origin := r.Header.Get("Origin"); origin != "" {
u, err := url.Parse(origin)
if err != nil {
httputils.ReportError(w, err, "Invalid Origin", http.StatusInternalServerError)
return
}
if strings.HasSuffix(u.Host, "."+COOKIE_DOMAIN_SKIA_ORG) ||
strings.HasSuffix(u.Host, "."+COOKIE_DOMAIN_SKIA_CORP) ||
strings.HasPrefix(u.Host, "localhost:") {
prefix := "https://"
if strings.HasPrefix(u.Host, "localhost:") {
prefix = "http://"
}
w.Header().Add("Access-Control-Allow-Origin", prefix+u.Host)
w.Header().Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Add("Access-Control-Allow-Credentials", "true")
w.Header().Add("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
}
}
if r.Method == "OPTIONS" {
return
}
if err := enc.Encode(body); err != nil {
sklog.Errorf("Failed to encode Login status to JSON: %s", err)
}
}
// ForceAuthMiddleware does ForceAuth by returning a func that can be used as
// middleware.
func ForceAuthMiddleware(oauthCallbackPath string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return ForceAuth(h, oauthCallbackPath)
}
}
// 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 strings.HasPrefix(r.URL.Path, oauthCallbackPath) {
// If this is the oauth2 callback, run that handler.
OAuth2CallbackHandler(w, r)
return
} else {
// If this is not the oauth callback then redirect.
redirectUrl := LoginURL(w, r)
sklog.Infof("Redirect URL: %s", redirectUrl)
if redirectUrl == "" {
httputils.ReportError(w, fmt.Errorf("Unable to get redirect URL."), "Redirect to login failed:", http.StatusInternalServerError)
return
}
http.Redirect(w, r, redirectUrl, http.StatusTemporaryRedirect)
return
}
}
h.ServeHTTP(w, r)
})
}
// RestrictWithMessage returns a middleware func which enforces that the user
// is logged in with an allowed account before the wrapped handler is called. It
// uses the given message when a user is denied access.
func RestrictWithMessage(allow allowed.Allow, msg string) func(http.Handler) http.Handler {
if allow == nil {
return func(h http.Handler) http.Handler { return h }
}
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
email := LoggedInAs(r)
if !allow.Member(email) {
sklog.Warningf("%s: %s", msg, email)
http.Error(w, msg, 403)
return
}
h.ServeHTTP(w, r)
})
}
}
// Restrict returns a middleware func which enforces that the user is logged
// in with an allowed account before the wrapped handler is called.
func Restrict(allow allowed.Allow) func(http.Handler) http.Handler {
return RestrictWithMessage(allow, "User is not in allowed list")
}
// RestrictAdmin is middleware which enforces that the user is logged in as an
// admin before the wrapped handler is called. Filled in during InitWithAllow.
var RestrictAdmin = func(h http.Handler) http.Handler {
sklog.Fatal("RestrictAdmin called but not configured with InitWithAllow.")
return h
}
// RestrictEditor is middleware which enforces that the user is logged in as an
// editor before the wrapped handler is called. Filled in during InitWithAllow.
var RestrictEditor = func(h http.Handler) http.Handler {
sklog.Fatal("RestrictEditor called but not configured with InitWithAllow.")
return h
}
// RestrictViewer is middleware which enforces that the user is logged in as a
// viewer before the wrapped handler is called. Filled in during InitWithAllow.
var RestrictViewer = func(h http.Handler) http.Handler {
sklog.Fatal("RestrictViewer called but not configured with InitWithAllow.")
return h
}
// RestrictFn wraps an http.HandlerFunc, restricting it to the given allowed list.
func RestrictFn(h http.HandlerFunc, allow allowed.Allow) http.HandlerFunc {
return Restrict(allow)(h).(http.HandlerFunc)
}
// RestrictAdminFn wraps an http.HandlerFunc, restricting it to admins.
func RestrictAdminFn(h http.HandlerFunc) http.HandlerFunc {
return RestrictAdmin(h).(http.HandlerFunc)
}
// RestrictEditorFn wraps an http.HandlerFunc, restricting it to editors.
func RestrictEditorFn(h http.HandlerFunc) http.HandlerFunc {
return RestrictEditor(h).(http.HandlerFunc)
}
// RestrictViewerFn wraps an http.HandlerFunc, restricting it to viewers.
func RestrictViewerFn(h http.HandlerFunc) http.HandlerFunc {
return RestrictViewer(h).(http.HandlerFunc)
}
// splitAuthWhiteList splits the given whitelist into a set of domains and a set of
// individual emails
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) {
if adminAllow != nil || editAllow != nil || viewAllow != nil {
return
}
activeUserDomainWhiteList, activeUserEmailWhiteList = splitAuthWhiteList(authWhiteList)
adminWhiteList := metadata.ProjectGetWithDefault(metadata.ADMIN_WHITE_LIST, DEFAULT_ADMIN_WHITELIST)
_, activeAdminEmailWhiteList = splitAuthWhiteList(adminWhiteList)
}
// loginInfo is the JSON file format that client info is stored in as a kubernetes secret.
type loginInfo struct {
Salt string `json:"salt"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
// tryLoadingFromKnownLocations tries to load the cookie salt, client id, and
// client secret from a file in a known location, and then 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 tryLoadingFromKnownLocations() (string, string, string) {
cookieSalt := ""
clientID := ""
clientSecret := ""
var info loginInfo
err := util.WithReadFile(LOGIN_CONFIG_FILE, func(f io.Reader) error {
if err := json.NewDecoder(f).Decode(&info); err != nil {
return err
}
cookieSalt = info.Salt
clientID = info.ClientID
clientSecret = info.ClientSecret
return nil
})
if err == nil {
sklog.Infof("Successfully loaded login secrets from file %s.", LOGIN_CONFIG_FILE)
return cookieSalt, clientID, clientSecret
}
sklog.Infof("Failed to load login secrets from file %s. Got error: %s", LOGIN_CONFIG_FILE, err)
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
}
// ViaBearerToken tries to load an OAuth 2.0 Bearer token from from the request
// and derives the login email address from it.
func ViaBearerToken(r *http.Request) (string, error) {
tok := r.Header.Get("Authorization")
if tok == "" {
return "", errors.New("User is not authenticated.")
}
tok = strings.TrimPrefix(tok, "Bearer ")
tokenInfo, err := ValidateBearerToken(tok)
if err != nil {
return "", err
}
return tokenInfo.Email, nil
}
// ValidateBearerToken takes an OAuth 2.0 Bearer token (e.g. The third part of
// Authorization: Bearer ya29.Elj...
// and polls a Google HTTP endpoint to see if is valid. This is fine in low-volumne
// situations, but another solution may be needed if this goes higher than a few QPS.
func ValidateBearerToken(token string) (*oauth2_api.Tokeninfo, error) {
c, err := oauth2_api.New(httputils.NewTimeoutClient())
if err != nil {
return nil, fmt.Errorf("could not make oauth2 api client: %s", err)
}
ti, err := c.Tokeninfo().AccessToken(token).Do()
if err != nil {
return nil, err
}
if ti.ExpiresIn <= 0 {
return nil, fmt.Errorf("Token is expired.")
}
if !ti.VerifiedEmail {
return nil, fmt.Errorf("Email not verified.")
}
return ti, nil
}