blob: 0edf14236cfe2544188e99ecc527598658492ab0 [file] [log] [blame]
// Package googleoauth implements an http.Handler that uses OAuth 2.0 to
// authenticate with Google and then renders a page that displays all the
// blessings that were provided for that Google user.
//
// The GoogleIDToken is currently validated by sending an HTTP request to
// googleapis.com. This adds a round-trip and service may be denied by
// googleapis.com if this handler becomes a breakout success and receives tons
// of traffic. If either is a concern, the GoogleIDToken can be validated
// without an additional HTTP request.
// See: https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken
package googleoauth
import (
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"path"
"strings"
"time"
"code.google.com/p/goauth2/oauth"
"veyron.io/veyron/veyron/services/identity/auditor"
"veyron.io/veyron/veyron/services/identity/revocation"
"veyron.io/veyron/veyron/services/identity/util"
"veyron.io/veyron/veyron2/security"
"veyron.io/veyron/veyron2/vlog"
)
const (
clientIDCookie = "VeyronHTTPIdentityClientID"
revocationCookie = "VeyronHTTPIdentityRevocationID"
)
type HandlerArgs struct {
// URL at which the hander is installed.
// e.g. http://host:port/google/
// This is where the handler is installed and where redirect requests
// from Google will come to.
Addr string
// client_id and client_secret registered with the Google Developer
// Console for API access.
ClientID, ClientSecret string
// Prefix for the audit log from which data will be sourced.
// (auditor.ReadAuditLog).
Auditor string
// The RevocationManager is used to revoke blessings granted with a revocation caveat.
RevocationManager *revocation.RevocationManager
}
func (a *HandlerArgs) redirectURL() string {
ret := a.Addr
if !strings.HasSuffix(ret, "/") {
ret += "/"
}
ret += "oauth2callback"
return ret
}
// URL used to verify google tokens.
// (From https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken
// and https://developers.google.com/accounts/docs/OAuth2UserAgent#validatetoken)
const verifyURL = "https://www.googleapis.com/oauth2/v1/tokeninfo?"
// NewHandler returns an http.Handler that expects to be rooted at args.Addr
// and can be used to use OAuth 2.0 to authenticate with Google, mint a new
// identity and bless it with the Google email address.
func NewHandler(args HandlerArgs) (http.Handler, error) {
config := NewOAuthConfig(args.ClientID, args.ClientSecret, args.redirectURL())
csrfCop, err := util.NewCSRFCop()
if err != nil {
return nil, err
}
return &handler{
config: config,
csrfCop: csrfCop,
auditor: args.Auditor,
revocationManager: args.RevocationManager,
}, nil
}
// NewOAuthConfig returns the oauth.Config required for obtaining just the email address from Google using OAuth 2.0.
func NewOAuthConfig(clientID, clientSecret, redirectURL string) *oauth.Config {
return &oauth.Config{
ClientId: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scope: "email",
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://accounts.google.com/o/oauth2/token",
}
}
type handler struct {
config *oauth.Config
csrfCop *util.CSRFCop
auditor string
revocationManager *revocation.RevocationManager
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch path.Base(r.URL.Path) {
case "auth":
h.auth(w, r)
case "oauth2callback":
h.callback(w, r)
case "revoke":
h.revoke(w, r)
default:
util.HTTPBadRequest(w, r, nil)
}
}
func (h *handler) auth(w http.ResponseWriter, r *http.Request) {
csrf, err := h.csrfCop.NewToken(w, r, clientIDCookie, nil)
if err != nil {
vlog.Infof("Failed to create CSRF token[%v] for request %#v", err, r)
util.HTTPBadRequest(w, r, fmt.Errorf("Suspected automated request: %v", err))
return
}
http.Redirect(w, r, h.config.AuthCodeURL(csrf), http.StatusFound)
}
func (h *handler) callback(w http.ResponseWriter, r *http.Request) {
if err := h.csrfCop.ValidateToken(r.FormValue("state"), r, clientIDCookie, nil); err != nil {
vlog.Infof("Invalid CSRF token: %v in request: %#v", err, r)
util.HTTPBadRequest(w, r, fmt.Errorf("Suspected request forgery: %v", err))
return
}
email, err := ExchangeAuthCodeForEmail(h.config, r.FormValue("code"))
if err != nil {
util.HTTPBadRequest(w, r, err)
return
}
type tmplentry struct {
Blessee security.PublicID
Start, End time.Time
Blessed security.PublicID
RevocationTime time.Time
Token string
}
tmplargs := struct {
Log chan tmplentry
Email, Token string
}{
Log: make(chan tmplentry),
Email: email,
}
if entrych, err := auditor.ReadAuditLog(h.auditor, email); err != nil {
vlog.Errorf("Unable to read audit log: %v", err)
util.HTTPServerError(w, fmt.Errorf("unable to read audit log"))
return
} else {
go func(ch chan tmplentry) {
defer close(ch)
for entry := range entrych {
if entry.Method != "Bless" || len(entry.Arguments) < 2 {
continue
}
var tmplentry tmplentry
var blessEntry revocation.BlessingAuditEntry
blessEntry, err = revocation.ReadBlessAuditEntry(entry)
tmplentry.Blessee = blessEntry.Blessee
tmplentry.Blessed = blessEntry.Blessed
tmplentry.Start = blessEntry.Start
tmplentry.End = blessEntry.End
if err != nil {
vlog.Errorf("Unable to read bless audit entry: %v", err)
continue
}
if blessEntry.RevocationCaveat != nil {
if revocationTime := h.revocationManager.GetRevocationTime(blessEntry.RevocationCaveat.ID()); revocationTime != nil {
tmplentry.RevocationTime = *revocationTime
} else {
caveatID := base64.URLEncoding.EncodeToString([]byte(blessEntry.RevocationCaveat.ID()))
if tmplentry.Token, err = h.csrfCop.NewToken(w, r, revocationCookie, caveatID); err != nil {
vlog.Infof("Failed to create CSRF token[%v] for request %#v", err, r)
util.HTTPBadRequest(w, r, fmt.Errorf("Suspected automated request: %v", err))
return
}
}
}
ch <- tmplentry
}
}(tmplargs.Log)
}
w.Header().Set("Context-Type", "text/html")
// This MaybeSetCookie call is needed to ensure that a cookie is created. Since the
// header cannot be changed once the body is written to, this needs to be called first.
if _, err = h.csrfCop.MaybeSetCookie(w, r, revocationCookie); err != nil {
vlog.Infof("Failed to set CSRF cookie[%v] for request %#v", err, r)
util.HTTPBadRequest(w, r, fmt.Errorf("Suspected automated request: %v", err))
return
}
if err := tmpl.Execute(w, tmplargs); err != nil {
vlog.Errorf("Unable to execute audit page template: %v", err)
util.HTTPServerError(w, err)
}
}
func (h *handler) revoke(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
const (
success = `{"success": "true"}`
failure = `{"success": "false"}`
)
if h.revocationManager == nil {
vlog.Infof("no provided revocation manager")
w.Write([]byte(failure))
return
}
content, err := ioutil.ReadAll(r.Body)
if err != nil {
vlog.Infof("Failed to parse request: %s", err)
w.Write([]byte(failure))
return
}
var requestParams struct {
Token string
}
if err := json.Unmarshal(content, &requestParams); err != nil {
vlog.Infof("json.Unmarshal failed : %s", err)
w.Write([]byte(failure))
return
}
var caveatID string
if caveatID, err = h.validateRevocationToken(requestParams.Token, r); err != nil {
vlog.Infof("failed to validate token for caveat: %s", err)
w.Write([]byte(failure))
return
}
if err := h.revocationManager.Revoke(caveatID); err != nil {
vlog.Infof("Revocation failed: %s", err)
w.Write([]byte(failure))
return
}
w.Write([]byte(success))
return
}
func (h *handler) validateRevocationToken(Token string, r *http.Request) (string, error) {
var encCaveatID string
if err := h.csrfCop.ValidateToken(Token, r, revocationCookie, &encCaveatID); err != nil {
return "", fmt.Errorf("invalid CSRF token: %v in request: %#v", err, r)
}
caveatID, err := base64.URLEncoding.DecodeString(encCaveatID)
if err != nil {
return "", fmt.Errorf("decode caveatID failed: %v", err)
}
return string(caveatID), nil
}
// ExchangeAuthCodeForEmail exchanges the authorization code (which must
// have been obtained with scope=email) for an OAuth token and then uses Google's
// tokeninfo API to extract the email address from that token.
func ExchangeAuthCodeForEmail(config *oauth.Config, authcode string) (string, error) {
t, err := (&oauth.Transport{Config: config}).Exchange(authcode)
if err != nil {
return "", fmt.Errorf("failed to exchange authorization code for token: %v", err)
}
// Ideally, would validate the token ourselves without an HTTP roundtrip.
// However, for now, as per:
// https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken
// pay an HTTP round-trip to have Google do this.
if t.Extra == nil || len(t.Extra["id_token"]) == 0 {
return "", fmt.Errorf("no GoogleIDToken found in OAuth token")
}
tinfo, err := http.Get(verifyURL + "id_token=" + t.Extra["id_token"])
if err != nil {
return "", fmt.Errorf("failed to talk to GoogleIDToken verifier (%q): %v", verifyURL, err)
}
if tinfo.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to verify GoogleIDToken: %s", tinfo.Status)
}
var gtoken token
if err := json.NewDecoder(tinfo.Body).Decode(&gtoken); err != nil {
return "", fmt.Errorf("invalid JSON response from Google's tokeninfo API: %v", err)
}
if !gtoken.VerifiedEmail {
return "", fmt.Errorf("email not verified: %#v", gtoken)
}
if gtoken.Issuer != "accounts.google.com" {
return "", fmt.Errorf("invalid issuer: %v", gtoken.Issuer)
}
if gtoken.Audience != config.ClientId {
return "", fmt.Errorf("unexpected audience(%v) in GoogleIDToken", gtoken.Audience)
}
return gtoken.Email, nil
}
// IDToken JSON message returned by Google's verification endpoint.
//
// This differs from the description in:
// https://developers.google.com/accounts/docs/OAuth2Login#obtainuserinfo
// because the Google tokeninfo endpoint
// (https://www.googleapis.com/oauth2/v1/tokeninfo?id_token=XYZ123)
// mentioned in:
// https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken
// seems to return the following JSON message.
type token struct {
Issuer string `json:"issuer"`
IssuedTo string `json:"issued_to"`
Audience string `json:"audience"`
UserID string `json:"user_id"`
ExpiresIn int64 `json:"expires_in"`
IssuedAt int64 `json:"issued_at"`
Email string `json:"email"`
VerifiedEmail bool `json:"verified_email"`
}