283 lines
8.7 KiB
Go
283 lines
8.7 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/gob"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ericchiang/oidc"
|
|
"github.com/gorilla/securecookie"
|
|
|
|
"golang.org/x/net/context"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
const (
|
|
cookieName = "oidc-proxy"
|
|
// This header will be set by oidcproxy during authentication and
|
|
// passed to the backend.
|
|
emailHeaderName = "X-User-Email"
|
|
)
|
|
|
|
// Session represents a logged in user's active session.
|
|
type Session struct {
|
|
Email string
|
|
Expires time.Time
|
|
}
|
|
|
|
func init() {
|
|
gob.Register(&Session{})
|
|
}
|
|
|
|
var (
|
|
// Flags.
|
|
issuer string
|
|
backend string
|
|
scopes string
|
|
allow string
|
|
httpAddr string
|
|
httpsAddr string
|
|
cookieExp time.Duration
|
|
|
|
// Set up during initial configuration.
|
|
oauth2Config = new(oauth2.Config)
|
|
oidcProvider *oidc.Provider
|
|
backendHandler *httputil.ReverseProxy
|
|
verifier *oidc.IDTokenVerifier
|
|
|
|
// Regexps of emails to allow.
|
|
allowEmail []*regexp.Regexp
|
|
|
|
nonceSource *memNonceSource
|
|
|
|
cookieEncrypter *securecookie.SecureCookie
|
|
)
|
|
|
|
func main() {
|
|
flag.StringVar(&issuer, "issuer", "https://accounts.google.com", "The issuer URL of the OpenID Connect provider.")
|
|
flag.StringVar(&backend, "backend", "", "The URL of the backened to proxy to.")
|
|
flag.StringVar(&oauth2Config.RedirectURL, "redirect-url", "", "A full OAuth2 redirect URL.")
|
|
flag.StringVar(&oauth2Config.ClientID, "client-id", "", "The client ID of the OAuth2 client.")
|
|
flag.StringVar(&oauth2Config.ClientSecret, "client-secret", "", "The client secret of the OAuth2 client.")
|
|
flag.StringVar(&scopes, "scopes", "openid,email,profile", `A comma seprated list of OAuth2 scopes to request ("openid" required).`)
|
|
flag.StringVar(&allow, "allow-email", ".*", "Comma seperated list of email regexp's to match for access to the backend.")
|
|
flag.StringVar(&httpAddr, "http", "127.0.0.1:5556", "Default address to listen on.")
|
|
flag.DurationVar(&cookieExp, "cookie-exp", time.Hour*24, "Duration for which a login cookie is valid for.")
|
|
flag.Parse()
|
|
|
|
// Set flags from environment variables.
|
|
flag.VisitAll(func(f *flag.Flag) {
|
|
if f.Value.String() != f.DefValue {
|
|
return
|
|
}
|
|
|
|
// Convert flag name, e.g. "redirect-url" becomes "OIDC_PROXY_REDIRECT_URL"
|
|
envVar := "OIDC_PROXY_" + strings.ToUpper(strings.Replace(f.Name, "-", "_", -1))
|
|
|
|
if envVal := os.Getenv(envVar); envVal != "" {
|
|
if err := flag.Set(f.Name, envVal); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
// All flags are manditory.
|
|
if f.Value.String() == "" {
|
|
flag.Usage()
|
|
os.Exit(2)
|
|
}
|
|
})
|
|
|
|
// compile email regexps
|
|
for _, expr := range strings.Split(allow, ",") {
|
|
allowEmailRegexp, err := regexp.Compile(expr)
|
|
if err != nil {
|
|
log.Fatalf("invalid regexp: %q %v", expr, err)
|
|
}
|
|
allowEmail = append(allowEmail, allowEmailRegexp)
|
|
}
|
|
|
|
// configure reverse proxy
|
|
backendURL, err := url.Parse(backend)
|
|
if err != nil {
|
|
log.Fatalf("failed to parse backend: %v", err)
|
|
}
|
|
backendHandler = httputil.NewSingleHostReverseProxy(backendURL)
|
|
|
|
redirectURL, err := url.Parse(oauth2Config.RedirectURL)
|
|
if err != nil {
|
|
log.Fatalf("failed to parse redirect URL: %v", err)
|
|
}
|
|
|
|
// Query for the provider.
|
|
oidcProvider, err = oidc.NewProvider(context.TODO(), issuer)
|
|
if err != nil {
|
|
log.Fatalf("failed to get provider: %v", err)
|
|
}
|
|
|
|
nonceSource = newNonceSource(context.TODO())
|
|
verifier = oidcProvider.NewVerifier(context.TODO(), oidc.VerifyNonce(nonceSource))
|
|
|
|
oauth2Config.Endpoint = oidcProvider.Endpoint()
|
|
oauth2Config.Scopes = strings.Split(scopes, ",")
|
|
|
|
// Initialize secure cookies.
|
|
// TODO(ericchiang): make these configurable
|
|
hashKey := make([]byte, 64)
|
|
blockKey := make([]byte, 32)
|
|
if _, err := io.ReadFull(rand.Reader, hashKey); err != nil {
|
|
log.Fatalf("failed to initialize hash key: %v", err)
|
|
}
|
|
if _, err := io.ReadFull(rand.Reader, blockKey); err != nil {
|
|
log.Fatalf("failed to initialize block key: %v", err)
|
|
}
|
|
cookieEncrypter = securecookie.New(hashKey, blockKey)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", handleProxy)
|
|
mux.HandleFunc("/login", handleRedirect)
|
|
mux.HandleFunc("/logout", handleLogout)
|
|
mux.HandleFunc(redirectURL.Path, handleCallback)
|
|
|
|
log.Printf("Listening on: %s", httpAddr)
|
|
http.ListenAndServe(httpAddr, mux)
|
|
}
|
|
|
|
// httpRedirect returns a handler which redirects to the provided path.
|
|
func httpRedirect(path string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, path, http.StatusFound)
|
|
})
|
|
}
|
|
|
|
// httpError returns a handler which presents an error to the end user.
|
|
func httpError(status int, format string, a ...interface{}) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, fmt.Sprintf(format, a...), http.StatusInternalServerError)
|
|
})
|
|
}
|
|
|
|
func handleCallback(w http.ResponseWriter, r *http.Request) {
|
|
func() http.Handler {
|
|
state := r.URL.Query().Get("state")
|
|
if state == "" {
|
|
log.Printf("State not set")
|
|
return httpError(http.StatusInternalServerError, "Authentication failed")
|
|
}
|
|
if err := nonceSource.ClaimNonce(state); err != nil {
|
|
log.Printf("Failed to claim nonce: %v", err)
|
|
return httpError(http.StatusInternalServerError, "Authentication failed")
|
|
}
|
|
|
|
oauth2Token, err := oauth2Config.Exchange(context.TODO(), r.URL.Query().Get("code"))
|
|
if err != nil {
|
|
log.Printf("Failed to exchange token: %v", err)
|
|
return httpError(http.StatusInternalServerError, "Authentication failed")
|
|
}
|
|
|
|
// Extract the ID Token from oauth2 token.
|
|
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
|
if !ok {
|
|
log.Println("No ID Token found")
|
|
return httpError(http.StatusInternalServerError, "Authentication failed")
|
|
}
|
|
|
|
idToken, err := verifier.Verify(rawIDToken)
|
|
if err != nil {
|
|
log.Printf("Failed to verify token: %v", err)
|
|
return httpError(http.StatusInternalServerError, "Authentication failed")
|
|
}
|
|
var claims struct {
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
}
|
|
|
|
if err := idToken.Claims(&claims); err != nil {
|
|
log.Printf("Failed to decode claims: %v", err)
|
|
return httpError(http.StatusInternalServerError, "Authentication failed")
|
|
}
|
|
if !claims.EmailVerified || claims.Email == "" {
|
|
log.Println("Failed to verify email")
|
|
return httpError(http.StatusInternalServerError, "Authentication failed")
|
|
}
|
|
|
|
s := Session{Email: claims.Email, Expires: time.Now().Add(cookieExp)}
|
|
encoded, err := cookieEncrypter.Encode(cookieName, s)
|
|
if err != nil {
|
|
log.Printf("Failed to encrypt session: %v", err)
|
|
return httpError(http.StatusInternalServerError, "Authentication failed")
|
|
}
|
|
|
|
// Set the encoded cookie
|
|
cookie := &http.Cookie{Name: cookieName, Value: encoded, HttpOnly: true, Path: "/"}
|
|
http.SetCookie(w, cookie)
|
|
return httpRedirect("/")
|
|
|
|
}().ServeHTTP(w, r)
|
|
}
|
|
|
|
func handleRedirect(w http.ResponseWriter, r *http.Request) {
|
|
// TODO(ericchiang): since arbitrary requests can create nonces, rate limit this endpoint.
|
|
func() http.Handler {
|
|
nonce, err := nonceSource.Nonce()
|
|
if err != nil {
|
|
log.Printf("Failed to create nonce: %v", err)
|
|
return httpError(http.StatusInternalServerError, "Failed to generate redirect")
|
|
}
|
|
state, err := nonceSource.Nonce()
|
|
if err != nil {
|
|
log.Printf("Failed to create state: %v", err)
|
|
return httpError(http.StatusInternalServerError, "Failed to generate redirect")
|
|
}
|
|
return httpRedirect(oauth2Config.AuthCodeURL(state, oauth2.ApprovalForce, oidc.Nonce(nonce)))
|
|
}().ServeHTTP(w, r)
|
|
}
|
|
|
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
cookie := &http.Cookie{Name: cookieName, Value: "", HttpOnly: true, Path: "/"}
|
|
http.SetCookie(w, cookie)
|
|
httpRedirect("/login").ServeHTTP(w, r)
|
|
}
|
|
|
|
func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|
func() http.Handler {
|
|
cookie, err := r.Cookie(cookieName)
|
|
if err != nil {
|
|
// Only error can be ErrNoCookie https://goo.gl/o5fZ49
|
|
return httpRedirect("/login")
|
|
}
|
|
var s Session
|
|
if err := cookieEncrypter.Decode(cookieName, cookie.Value, &s); err != nil {
|
|
log.Printf("Failed to decode cookie: %v", err)
|
|
return http.HandlerFunc(handleLogout) // clear the cookie
|
|
}
|
|
if time.Now().After(s.Expires) {
|
|
log.Printf("Cookie for %q expired", s.Email)
|
|
return http.HandlerFunc(handleLogout) // clear the cookie
|
|
}
|
|
|
|
for _, allow := range allowEmail {
|
|
if allow.MatchString(s.Email) {
|
|
r.Header.Set(emailHeaderName, s.Email)
|
|
return backendHandler
|
|
}
|
|
}
|
|
log.Printf("Denying %q", s.Email)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
resp := []byte(`<html><head></head><body>Provided email does not have permission to login. <a href="/logout">Try a different account.</a></body></html>`)
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(resp)))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write(resp)
|
|
})
|
|
}().ServeHTTP(w, r)
|
|
}
|