initial code import

This commit is contained in:
Jehiah Czebotar 2012-12-10 20:59:23 -05:00
parent c6f07d54d7
commit fb636396a3
9 changed files with 635 additions and 8 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
google_auth_proxy
# Go.gitignore
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe

View File

@ -28,12 +28,19 @@ individual accounts, or a whole google apps domain.
## Usage
```
./google_auth_proxy
Usage of ./google_auth_proxy:
-authenticated-emails-file="": authenticate against emails via file (one per line)
-client-id="": the Google OAuth Client ID: ie: "123456.apps.googleusercontent.com"
-client-secret="": the OAuth Client secret
-cookie-secret="": the seed for cookie values
-redirect-url="": the http base to redirect to. ie: https://internalapp.yourcompany.com/oauth2/callback
-htpasswd-file="": additionally lookup basic auth in a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption
-pass-basic-auth=true: pass basic auth information to upstream
-upstream=[]: the http url(s) of the upstream endpoint(s). If multiple, routing is based on URL path
```
-client-secret="": the OAuth Client Secret
-cookie-domain="": an optional cookie domain to force cookies to
-cookie-secret="": the seed string for secure cookies
-google-apps-domain="": authenticate against the given google apps domain
-htpasswd-file="": additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption
-http-address="0.0.0.0:4180": <addr>:<port> to listen on for HTTP clients
-pass-basic-auth=true: pass HTTP Basic Auth information to upstream
-redirect-url="": the OAuth Redirect URL. ie: "https://internalapp.yourcompany.com/oauth2/callback"
-upstream=[]: the http url(s) of the upstream endpoint. If multiple, routing is based on path
-version=false: print version string
```
Unauthenticated requests will be redirected to `/oauth2/sign_in` to start the sign-in process.

50
cookies.go Normal file
View File

@ -0,0 +1,50 @@
package main
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
func validateCookie(cookie *http.Cookie, seed string) (string, bool) {
// value, timestamp, sig
parts := strings.Split(cookie.Value, "|")
if len(parts) != 3 {
return "", false
}
sig := cookieSignature(seed, cookie.Name, parts[0], parts[1])
if parts[2] == sig {
ts, err := strconv.Atoi(parts[1])
if err == nil && int64(ts) > time.Now().Add(time.Duration(24)*7*time.Hour*-1).Unix() {
// it's a valid cookie. now get the contents
rawValue, err := base64.URLEncoding.DecodeString(parts[0])
if err == nil {
return string(rawValue), true
}
}
}
return "", false
}
func signedCookieValue(seed string, key string, value string) string {
encodedValue := base64.URLEncoding.EncodeToString([]byte(value))
timeStr := fmt.Sprintf("%d", time.Now().Unix())
sig := cookieSignature(seed, key, encodedValue, timeStr)
cookieVal := fmt.Sprintf("%s|%s|%s", encodedValue, timeStr, sig)
return cookieVal
}
func cookieSignature(args ...string) string {
h := hmac.New(sha1.New, []byte(args[0]))
for _, arg := range args[1:] {
h.Write([]byte(arg))
}
var b []byte
b = h.Sum(b)
return base64.URLEncoding.EncodeToString(b)
}

55
htpasswd.go Normal file
View File

@ -0,0 +1,55 @@
package main
import (
"crypto/sha1"
"encoding/base64"
"encoding/csv"
"log"
"os"
)
// lookup passwords in a htpasswd file
// The entries must have been created with -s for SHA encryption
type HtpasswdFile struct {
Users map[string]string
}
func NewHtpasswdFile(path string) *HtpasswdFile {
log.Printf("using htpasswd file %s", path)
r, err := os.Open(path)
if err != nil {
log.Fatalf("failed opening %v, %s", path, err.Error())
}
csv_reader := csv.NewReader(r)
csv_reader.Comma = ':'
csv_reader.Comment = '#'
csv_reader.TrimLeadingSpace = true
records, err := csv_reader.ReadAll()
if err != nil {
log.Fatalf("Failed reading file %s", err.Error())
}
h := &HtpasswdFile{Users: make(map[string]string)}
for _, record := range records {
h.Users[record[0]] = record[1]
}
return h
}
func (h *HtpasswdFile) Validate(user string, password string) bool {
realPassword, exists := h.Users[user]
if !exists {
return false
}
if realPassword[:5] == "{SHA}" {
d := sha1.New()
d.Write([]byte(password))
if realPassword[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) {
return true
}
} else {
log.Printf("Invalid htpasswd entry for %s. Must be a SHA entry.", user)
}
return false
}

90
main.go Normal file
View File

@ -0,0 +1,90 @@
package main
import (
"flag"
"log"
"net"
"net/http"
"net/url"
"strings"
"fmt"
)
const VERSION = "0.0.1"
var (
showVersion = flag.Bool("version", false, "print version string")
httpAddr = flag.String("http-address", "0.0.0.0:4180", "<addr>:<port> to listen on for HTTP clients")
redirectUrl = flag.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"")
clientID = flag.String("client-id", "", "the Google OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"")
clientSecret = flag.String("client-secret", "", "the OAuth Client Secret")
passBasicAuth = flag.Bool("pass-basic-auth", true, "pass HTTP Basic Auth information to upstream")
htpasswdFile = flag.String("htpasswd-file", "", "additionally authenticate against a htpasswd file. Entries must be created with \"htpasswd -s\" for SHA encryption")
cookieSecret = flag.String("cookie-secret", "", "the seed string for secure cookies")
cookieDomain = flag.String("cookie-domain", "", "an optional cookie domain to force cookies to")
googleAppsDomain = flag.String("google-apps-domain", "", "authenticate against the given google apps domain")
authenticatedEmailsFile = flag.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)")
upstreams = StringArray{}
)
func init() {
flag.Var(&upstreams, "upstream", "the http url(s) of the upstream endpoint. If multiple, routing is based on path")
}
func main() {
flag.Parse()
if *showVersion {
fmt.Printf("google_auth_proxy v%s\n", VERSION)
return
}
if len(upstreams) < 1 {
log.Fatal("missing --upstream")
}
if *cookieSecret == "" {
log.Fatal("missing --cookie-secret")
}
if *clientID == "" {
log.Fatal("missing --client-id")
}
if *clientSecret == "" {
log.Fatal("missing --client-secret")
}
var upstreamUrls []*url.URL
for _, u := range upstreams {
upstreamUrl, err := url.Parse(u)
if err != nil {
log.Fatalf("error parsing --upstream %s", err.Error())
}
upstreamUrls = append(upstreamUrls, upstreamUrl)
}
redirectUrl, err := url.Parse(*redirectUrl)
if err != nil {
log.Fatalf("error parsing --redirect-url %s", err.Error())
}
validator := NewValidator(*googleAppsDomain, *authenticatedEmailsFile)
oauthproxy := NewOauthProxy(upstreamUrls, *clientID, *clientSecret, validator)
oauthproxy.SetRedirectUrl(redirectUrl)
if *googleAppsDomain != "" && *authenticatedEmailsFile == "" {
oauthproxy.SignInMessage = fmt.Sprintf("using a %s email address", *googleAppsDomain)
}
if *htpasswdFile != "" {
oauthproxy.HtpasswdFile = NewHtpasswdFile(*htpasswdFile)
}
listener, err := net.Listen("tcp", *httpAddr)
if err != nil {
log.Fatalf("FATAL: listen (%s) failed - %s", *httpAddr, err.Error())
}
log.Printf("listening on %s", *httpAddr)
server := &http.Server{Handler: oauthproxy}
err = server.Serve(listener)
if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
log.Printf("ERROR: http.Serve() - %s", err.Error())
}
log.Printf("HTTP: closing %s", listener.Addr().String())
}

302
oauthproxy.go Normal file
View File

@ -0,0 +1,302 @@
package main
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"github.com/bitly/go-simplejson"
"io/ioutil"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
)
const signInPath = "/oauth2/sign_in"
const oauthStartPath = "/oauth2/start"
const oauthCallbackPath = "/oauth2/callback"
type OauthProxy struct {
CookieSeed string
CookieKey string
Validator func(string) bool
redirectUrl *url.URL // the url to receive requests at
oauthRedemptionUrl *url.URL // endpoint to redeem the code
oauthLoginUrl *url.URL // to redirect the user to
oauthUserInfoUrl *url.URL
oauthScope string
clientID string
clientSecret string
SignInMessage string
HtpasswdFile *HtpasswdFile
serveMux *http.ServeMux
}
func NewOauthProxy(proxyUrls []*url.URL, clientID string, clientSecret string, validator func(string) bool) *OauthProxy {
login, _ := url.Parse("https://accounts.google.com/o/oauth2/auth")
redeem, _ := url.Parse("https://accounts.google.com/o/oauth2/token")
info, _ := url.Parse("https://www.googleapis.com/oauth2/v2/userinfo")
serveMux := http.NewServeMux()
for _, u := range proxyUrls {
path := u.Path
u.Path = ""
log.Printf("mapping %s => %s", path, u)
serveMux.Handle(path, httputil.NewSingleHostReverseProxy(u))
}
return &OauthProxy{
CookieKey: "_oauthproxy",
CookieSeed: *cookieSecret,
Validator: validator,
clientID: clientID,
clientSecret: clientSecret,
oauthScope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
oauthRedemptionUrl: redeem,
oauthLoginUrl: login,
oauthUserInfoUrl: info,
serveMux: serveMux,
}
}
func (p *OauthProxy) SetRedirectUrl(redirectUrl *url.URL) {
redirectUrl.Path = oauthCallbackPath
p.redirectUrl = redirectUrl
}
func (p *OauthProxy) GetLoginURL() string {
params := url.Values{}
params.Add("redirect_uri", p.redirectUrl.String())
params.Add("approval_prompt", "force")
params.Add("scope", p.oauthScope)
params.Add("client_id", p.clientID)
params.Add("response_type", "code")
return fmt.Sprintf("%s?%s", p.oauthLoginUrl, params.Encode())
}
func apiRequest(req *http.Request) (*simplejson.Json, error) {
httpclient := &http.Client{}
resp, err := httpclient.Do(req)
if err != nil {
return nil, err
}
body, err := ioutil.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
log.Printf("got response code %d - %s", resp.StatusCode, body)
return nil, errors.New("api request returned 200 status code")
}
data, err := simplejson.NewJson(body)
if err != nil {
return nil, err
}
return data, nil
}
func (p *OauthProxy) redeemCode(code string) (string, error) {
params := url.Values{}
params.Add("redirect_uri", p.redirectUrl.String())
params.Add("client_id", p.clientID)
params.Add("client_secret", p.clientSecret)
params.Add("code", code)
params.Add("grant_type", "authorization_code")
log.Printf("body is %s", params.Encode())
req, err := http.NewRequest("POST", p.oauthRedemptionUrl.String(), bytes.NewBufferString(params.Encode()))
if err != nil {
log.Printf("failed building request %s", err.Error())
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
json, err := apiRequest(req)
if err != nil {
log.Printf("failed making request %s", err.Error())
return "", err
}
access_token, err := json.Get("access_token").String()
if err != nil {
return "", err
}
return access_token, nil
}
func (p *OauthProxy) getUserInfo(token string) (string, error) {
params := url.Values{}
params.Add("access_token", token)
endpoint := fmt.Sprintf("%s?%s", p.oauthUserInfoUrl.String(), params.Encode())
log.Printf("calling %s", endpoint)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
log.Printf("failed building request %s", err.Error())
return "", err
}
json, err := apiRequest(req)
if err != nil {
log.Printf("failed making request %s", err.Error())
return "", err
}
email, err := json.Get("email").String()
if err != nil {
log.Printf("failed getting email from response %s", err.Error())
return "", err
}
return email, nil
}
func ClearCookie(rw http.ResponseWriter, req *http.Request, key string) {
domain := strings.Split(req.Host, ":")[0]
if *cookieDomain != "" {
domain = *cookieDomain
}
cookie := &http.Cookie{
Name: key,
Value: "",
Path: "/",
Domain: domain,
Expires: time.Now().Add(time.Duration(1) * time.Hour * -1),
HttpOnly: true,
}
http.SetCookie(rw, cookie)
}
func ErrorPage(rw http.ResponseWriter, code int, title string, message string, signinmessage string) {
log.Printf("ErrorPage %d %s %s %s", code, title, message, signinmessage)
rw.WriteHeader(code)
t := getTemplates()
p := struct {
Title string
Message string
SignInMessage string
}{
Title: fmt.Sprintf("%d %s", code, title),
Message: message,
SignInMessage: signinmessage,
}
t.ExecuteTemplate(rw, "error.html", p)
}
func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// check if this is a redirect back at the end of oauth
if req.URL.Path == signInPath {
ClearCookie(rw, req, p.CookieKey)
t := getTemplates()
p := struct{ SignInMessage string }{SignInMessage: p.SignInMessage}
t.ExecuteTemplate(rw, "sign_in.html", p)
return
}
if req.URL.Path == oauthStartPath {
http.Redirect(rw, req, p.GetLoginURL(), 302)
return
}
if req.URL.Path == oauthCallbackPath {
// finish the oauth cycle
reqParams, err := url.ParseQuery(req.URL.RawQuery)
if err != nil {
ErrorPage(rw, 500, "Internal Error", err.Error(), p.SignInMessage)
return
}
errorString, ok := reqParams["error"]
if ok && len(errorString) == 1 {
ErrorPage(rw, 403, "Permission Denied", errorString[0], p.SignInMessage)
return
}
code, ok := reqParams["code"]
if !ok || len(code) != 1 {
ErrorPage(rw, 500, "Internal Error", "Invalid API response", p.SignInMessage)
return
}
token, err := p.redeemCode(code[0])
if err != nil {
log.Printf("error redeeming code %s", err.Error())
ErrorPage(rw, 500, "Internal Error", err.Error(), p.SignInMessage)
return
}
// validate user
email, err := p.getUserInfo(token)
if err != nil {
log.Printf("error redeeming code %s", err.Error())
ErrorPage(rw, 500, "Internal Error", err.Error(), p.SignInMessage)
return
}
// set cookie, or deny
if p.Validator(email) {
log.Printf("authenticating %s completed", email)
domain := strings.Split(req.Host, ":")[0]
if *cookieDomain != "" {
domain = *cookieDomain
}
cookie := &http.Cookie{
Name: p.CookieKey,
Value: signedCookieValue(p.CookieSeed, p.CookieKey, email),
Path: "/",
Domain: domain,
Expires: time.Now().Add(time.Duration(168) * time.Hour), // 7 days
HttpOnly: true,
// Secure: req. ... ? set if X-Scheme: https ?
}
http.SetCookie(rw, cookie)
http.Redirect(rw, req, "/", 302)
return
} else {
ErrorPage(rw, 403, "Permission Denied", "Invalid Account", p.SignInMessage)
return
}
}
cookie, err := req.Cookie(p.CookieKey)
var ok bool
var email string
var user string
if err == nil {
email, ok = validateCookie(cookie, p.CookieSeed)
user = strings.Split(email, "@")[0]
}
if !ok {
user, ok = p.CheckBasicAuth(req)
}
if !ok {
log.Printf("invalid cookie. redirecting to sign in")
// TODO: capture state for which url to redirect to at the end
http.Redirect(rw, req, "/oauth2/sign_in", 302)
return
}
// At this point, the user is authenticated. proxy normally
if *passBasicAuth {
req.SetBasicAuth(user, "")
req.Header["X-Forwarded-User"] = []string{user}
}
p.serveMux.ServeHTTP(rw, req)
}
func (p *OauthProxy) CheckBasicAuth(req *http.Request) (string, bool) {
if p.HtpasswdFile == nil {
return "", false
}
s := strings.SplitN(req.Header.Get("Authorization"), " ", 2)
if len(s) != 2 || s[0] != "Basic" {
return "", false
}
b, err := base64.StdEncoding.DecodeString(s[1])
if err != nil {
return "", false
}
pair := strings.SplitN(string(b), ":", 2)
if len(pair) != 2 {
return "", false
}
if p.HtpasswdFile.Validate(pair[0], pair[1]) {
return pair[0], true
}
return "", false
}

16
string_array.go Normal file
View File

@ -0,0 +1,16 @@
package main
import (
"fmt"
)
type StringArray []string
func (a *StringArray) Set(s string) error {
*a = append(*a, s)
return nil
}
func (a *StringArray) String() string {
return fmt.Sprint(*a)
}

37
templates.go Normal file
View File

@ -0,0 +1,37 @@
package main
import (
"html/template"
"log"
)
func getTemplates() *template.Template {
t, err := template.New("foo").Parse(`{{define "sign_in.html"}}
<html><head><title>Sign In</title></head>
<body>
<form method="GET" action="/oauth2/start">
<button type="submit">Sign In w/ Google</button>
{{.SignInMessage}}
</form>
</body></html>
{{end}}`)
if err != nil {
log.Fatalf("failed parsing template %s", err.Error())
}
t, err = t.Parse(`{{define "error.html"}}
<html><head><title>{{.Title}}</title></head>
<body>
<h2>{{.Title}}</h2>
<p>{{.Message}}</p>
<hr>
<form method="GET" action="/oauth2/start">
<button type="submit">Sign In w/ Google</button>
{{.SignInMessage}}
</form>
</body>
</html>{{end}}`)
if err != nil {
log.Fatalf("failed parsing template %s", err.Error())
}
return t
}

46
validator.go Normal file
View File

@ -0,0 +1,46 @@
package main
import (
"os"
"log"
"encoding/csv"
"fmt"
"strings"
)
func NewValidator(domain string, usersFile string) func(string) bool {
validUsers := make(map[string]bool)
emailSuffix := ""
if domain != "" {
emailSuffix = fmt.Sprintf("@%s", domain)
}
if usersFile != "" {
r, err := os.Open(usersFile)
if err != nil {
log.Fatalf("failed opening -authenticated-emails-file=%v, %s", usersFile, err.Error())
}
csv_reader := csv.NewReader(r)
csv_reader.Comma = ','
csv_reader.Comment = '#'
csv_reader.TrimLeadingSpace = true
records, err := csv_reader.ReadAll()
for _, r := range records {
validUsers[r[0]] = true
}
}
validator := func(email string) bool {
var valid bool
if emailSuffix != "" {
valid = strings.HasSuffix(email, emailSuffix)
}
if !valid {
_, valid = validUsers[email]
}
log.Printf("validating: is %s valid? %v", email, valid)
return valid
}
return validator
}