blob: 7a744e84fff3b6a866c9faed1bcbc8965266b492 [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 (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"code.google.com/p/goauth2/oauth"
"github.com/golang/glog"
"github.com/gorilla/securecookie"
"skia.googlesource.com/buildbot.git/go/util"
)
const (
COOKIE_NAME = "skid"
SESSION_COOKIE_NAME = "sksession"
)
var (
// cookieSalt is some entropy for our encoders.
cookieSalt = ""
secureCookie *securecookie.SecureCookie = nil
// oauthConfig is the OAuth 2.0 client configuration.
oauthConfig = &oauth.Config{
ClientId: "not-a-valid-client-id",
ClientSecret: "not-a-valid-client-secret",
Scope: "email",
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://accounts.google.com/o/oauth2/token",
RedirectURL: "http://localhost:8000/oauth2callback/",
// We don't need a refresh token, we'll just go through the approval flow again.
AccessType: "online",
// And when we go through the approval flow again don't stop if they've already approved once.
ApprovalPrompt: "auto",
}
// domainWhitelist is the list of domains that are allowed to log in to our site.
domainWhitelist = []string{"google.com", "chromium.org", "skia.org"}
)
// Init must be called before any other methods.
//
// The Client ID, Client Secret, and Redirect URL are listed in the Google
// Developers Console.
func Init(clientId, clientSecret, redirectURL, cookieSalt string) {
secureCookie = securecookie.New([]byte(cookieSalt), nil)
oauthConfig.ClientId = clientId
oauthConfig.ClientSecret = clientSecret
oauthConfig.RedirectURL = redirectURL
}
// 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 {
glog.Errorf("Failed to create a session token: %s", err)
return ""
}
cookie := &http.Cookie{
Name: SESSION_COOKIE_NAME,
Value: state,
Path: "/",
HttpOnly: true,
Expires: time.Now().Add(365 * 24 * time.Hour),
}
http.SetCookie(w, cookie)
} else {
state = session.Value
}
return oauthConfig.AuthCodeURL(state)
}
// 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 {
cookie, err := r.Cookie(COOKIE_NAME)
if err != nil {
return ""
}
var email string
if err := secureCookie.Decode(COOKIE_NAME, cookie.Value, &email); err != nil {
return ""
}
return email
}
// 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"`
}
// CookieFor creates an encoded Cookie for the given user id.
func CookieFor(value string) (*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: "/",
HttpOnly: true,
Expires: time.Now().Add(365 * 24 * time.Hour),
}, nil
}
func setSkIDCookieValue(w http.ResponseWriter, value string) {
cookie, err := CookieFor(value)
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) {
glog.Infof("LogoutHandler\n")
setSkIDCookieValue(w, "")
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) {
glog.Infof("OAuth2CallbackHandler\n")
session, err := r.Cookie(SESSION_COOKIE_NAME)
if err != nil || session.Value == "" {
http.Error(w, "Invalid session state.", 500)
return
}
state := r.FormValue("state")
if state != session.Value {
http.Error(w, "Session state doesn't match callback state.", 500)
return
}
code := r.FormValue("code")
glog.Infof("Code: %s ", code[:5])
transport := &oauth.Transport{
Config: oauthConfig,
Transport: &http.Transport{
Dial: util.DialTimeout,
},
}
token, err := transport.Exchange(code)
if err != nil {
glog.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 := token.Extra["id_token"]
// 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 {
glog.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 {
glog.Errorf("Failed to JSON decode token: %s", string(b))
http.Error(w, "Failed to JSON decode id_token.", 500)
return
}
parts := strings.Split(decoded.Email, "@")
if len(parts) != 2 {
http.Error(w, "Invalid email address received.", 500)
return
}
if !util.In(parts[1], domainWhitelist) {
http.Error(w, "Accounts from your domain are not allowed.", 500)
return
}
setSkIDCookieValue(w, decoded.Email)
http.Redirect(w, r, "/", 302)
}
// StatusHandler returns the login status of the user as JSON that looks like:
//
// {
// "Email": "fred@example.com",
// "LoginURL": "https://..."
// }
//
func StatusHandler(w http.ResponseWriter, r *http.Request) {
glog.Infof("StatusHandler\n")
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
body := map[string]string{
"Email": LoggedInAs(r),
"LoginURL": LoginURL(w, r),
}
if err := enc.Encode(body); err != nil {
glog.Errorf("Failed to encode Login status to JSON: %s", err)
}
}