initial commit

This commit is contained in:
Thom Seddon 2018-06-26 12:28:47 +01:00
commit 5b46b7a0aa
7 changed files with 852 additions and 0 deletions

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM golang:1.10-alpine as builder
# Setup
RUN mkdir /app
WORKDIR /app
# Add libraries
RUN apk add --no-cache git && \
go get "github.com/namsral/flag" && \
go get "github.com/op/go-logging" && \
apk del git
# Copy & build
ADD . /app/
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /traefik-forward-auth .
# Copy into scratch container
FROM scratch
COPY --from=builder /traefik-forward-auth ./
ENTRYPOINT ["./traefik-forward-auth"]

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) [2018] [Thom Seddon]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

64
README.md Normal file
View File

@ -0,0 +1,64 @@
# Traefik Forward Auth
A minimal forward authentication service that provides Google oauth based login and authentication for the traefik reverse proxy.
## Why?
- Seamlessly overlays any http service with a single endpoint (see: `-url-path` in [Configuration](#configuration))
- Supports multiple domains/subdomains
- Allows authentication to persist across multiple domains (see [Cookie Domains](#cookie-domains))
- Supports extended authentication beyond Google token lifetime (see: `-lifetime` in [Configuration](#configuration))
## Quick Start
See the (examples) directory for example docker compose and traefik configuration files that demonstrates the forward authentication configuration for traefik and passing required configuration values to traefik-forward-auth.
## Configuration
The following configuration is supported:
|Flag |Type |Description|
|-----------------------|------|-----------|
|-client-id|string|*Google Client ID (required)|
|-client-secret|string|*Google Client Secret (required)|
|-config|string|Path to config file|
|-cookie-domains|string|Comma separated list of cookie domains|
|-cookie-name|string|Cookie Name (default "_forward_auth")|
|-cookie-secret|string|*Cookie secret (required)|
|-cookie-secure|bool|Use secure cookies (default true)|
|-csrf-cookie-name|string|CSRF Cookie Name (default "_forward_auth_csrf")|
|-direct|bool|Run in direct mode (use own hostname as oppose to <br>X-Forwarded-Host, used for testing/development)
|-domain|string|Comma separated list of email domains to allow|
|-lifetime|int|Session length in seconds (default 43200)|
|-url-path|string|Callback URL (default "_oauth")|
Configuration can also be supplied as environment variables (use upper case and swap `-`'s for `_`'s e.g. `-client-id` becomes `CLIENT_ID`)
Configuration can also be supplied via a file, you can specify the location with `-config` flag, the format is `flag value` one per line, e.g. `client-id your-client-id`)
## OAuth Configuration
Head to https://console.developers.google.com & make sure you've switched to the correct email account.
Create a new project then search for and select "Credentials" in the search bar. Fill out the "OAuth Consent Screen" tab.
Click, "Create Credentials" > "OAuth client ID". Select "Web Application", fill in the name of your app, skip "Authorized JavaScript origins" and fill "Authorized redirect URIs" with all the domains you will allow authentication from, appended with the `url-path` (e.g. https://app.test.com/_oauth)
## Cookie Domains
You can supply a comma separated list of cookie domains, if the host of the original request is a subdomain of any given cookie domain, the authentication cookie will set with the given domain.
For example, if cookie domain is `test.com` and a request comes in on `app1.test.com`, the cookie will be set for the whole `test.com` domain. As such, if another request is forwarded for authentication from `app2.test.com`, the original cookie will be sent and so the request will be allowed without further authentication.
Beware however, if using cookie domains whilst running multiple instances of traefik/traefik-forward-auth for the same domain, the cookies will clash. You can fix this by using the same `cookie-secret` in both instances, or using a different `cookie-name` on each.
## Copyright
2018 Thom Seddon
## License
[MIT](https://github.com/thomseddon/traefik-forward-auth/blob/master/LICENSE.md)

View File

@ -0,0 +1,37 @@
version: '3'
services:
traefik:
image: traefik
command: -c /traefik.toml --logLevel=DEBUG
ports:
- "8085:80"
- "8086:8080"
networks:
- traefik
volumes:
- ./traefik.toml:/traefik.toml
- /var/run/docker.sock:/var/run/docker.sock
whoami1:
image: emilevauge/whoami
networks:
- traefik
labels:
- "traefik.backend=whoami"
- "traefik.enable=true"
- "traefik.frontend.rule=Host:whoami.localhost.com"
forward-oauth:
image: thomseddon/traefik-forward-auth
environment:
- CLIENT_ID=your-client-id
- CLIENT_SECRET=your-client-secret
- COOKIE_SECRET=something-random
- COOKIE_SECURE=false
- DOMAIN=yourcompany.com
networks:
- traefik
networks:
traefik:

136
example/traefik.toml Normal file
View File

@ -0,0 +1,136 @@
################################################################
# Global configuration
################################################################
# Enable debug mode
#
# Optional
# Default: false
#
# debug = true
# Log level
#
# Optional
# Default: "ERROR"
#
# logLevel = "DEBUG"
# Entrypoints to be used by frontends that do not specify any entrypoint.
# Each frontend can specify its own entrypoints.
#
# Optional
# Default: ["http"]
#
# defaultEntryPoints = ["http", "https"]
################################################################
# Entrypoints configuration
################################################################
# Entrypoints definition
#
# Optional
# Default:
[entryPoints]
[entryPoints.http]
address = ":80"
[entryPoints.http.auth.forward]
address = "http://forward-oauth:4181"
################################################################
# Traefik logs configuration
################################################################
# Traefik logs
# Enabled by default and log to stdout
#
# Optional
#
# [traefikLog]
# Sets the filepath for the traefik log. If not specified, stdout will be used.
# Intermediate directories are created if necessary.
#
# Optional
# Default: os.Stdout
#
# filePath = "log/traefik.log"
# Format is either "json" or "common".
#
# Optional
# Default: "common"
#
# format = "common"
################################################################
# Access logs configuration
################################################################
# Enable access logs
# By default it will write to stdout and produce logs in the textual
# Common Log Format (CLF), extended with additional fields.
#
# Optional
#
# [accessLog]
# Sets the file path for the access log. If not specified, stdout will be used.
# Intermediate directories are created if necessary.
#
# Optional
# Default: os.Stdout
#
# filePath = "/path/to/log/log.txt"
# Format is either "json" or "common".
#
# Optional
# Default: "common"
#
# format = "common"
################################################################
# API and dashboard configuration
################################################################
# Enable API and dashboard
[api]
# Name of the related entry point
#
# Optional
# Default: "traefik"
#
# entryPoint = "traefik"
# Enabled Dashboard
#
# Optional
# Default: true
#
# dashboard = false
################################################################
# Ping configuration
################################################################
# Enable ping
[ping]
# Name of the related entry point
#
# Optional
# Default: "traefik"
#
# entryPoint = "traefik"
################################################################
# Docker configuration backend
################################################################
# Enable Docker configuration backend
[docker]
exposedByDefault = false

358
forwardauth.go Normal file
View File

@ -0,0 +1,358 @@
package main
import (
"fmt"
"time"
"strings"
"strconv"
"net/url"
"net/http"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"encoding/base64"
)
// Forward Auth
type ForwardAuth struct {
Path string
PathLen int
Lifetime time.Duration
ClientId string
ClientSecret string
Scope string
LoginURL *url.URL
TokenURL *url.URL
UserURL *url.URL
CookieName string
CookieDomains []CookieDomain
CSRFCookieName string
CookieSecret []byte
CookieSecure bool
Domain []string
Direct bool
}
// Request Validation
// Cookie = hash(secret, cookie domain, email, expires)|expires|email
func (f *ForwardAuth) ValidateCookie(r *http.Request, c *http.Cookie) (bool, string) {
parts := strings.Split(c.Value, "|")
if len(parts) != 3 {
return false, ""
}
mac, err := base64.URLEncoding.DecodeString(parts[0])
if err != nil {
return false, ""
}
expectedSignature := f.cookieSignature(r, parts[2], parts[1])
expected, err := base64.URLEncoding.DecodeString(expectedSignature)
if err != nil {
return false, ""
}
// Valid token?
if !hmac.Equal(mac, expected) {
return false, ""
}
expires, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return false, ""
}
// Has it expired?
if time.Unix(expires, 0).Before(time.Now()) {
return false, ""
}
// Looks valid
return true, parts[2]
}
// Validate email
func (f *ForwardAuth) ValidateEmail(email string) bool {
if len(f.Domain) > 0 {
parts := strings.Split(email, "@")
if len(parts) < 2 {
return false
}
found := false
for _, domain := range f.Domain {
if domain == parts[1] {
found = true
}
}
if !found {
return false
}
}
return true
}
// OAuth Methods
// Get login url
func (f *ForwardAuth) GetLoginURL(r *http.Request, nonce string) string {
state := fmt.Sprintf("%s:%s", nonce, f.ReturnUrl(r))
q := url.Values{}
q.Set("client_id", fw.ClientId)
q.Set("response_type", "code")
q.Set("scope", fw.Scope)
// q.Set("approval_prompt", fw.ClientId)
q.Set("redirect_uri", f.RedirectUri(r))
q.Set("state", state)
var u url.URL
u = *fw.LoginURL
u.RawQuery = q.Encode()
return u.String()
}
// Exchange code for token
type Token struct {
Token string `json:"access_token"`
}
func (f *ForwardAuth) ExchangeCode(r *http.Request, code, redirect string) (string, error) {
form := url.Values{}
form.Set("client_id", fw.ClientId)
form.Set("client_secret", fw.ClientSecret)
form.Set("grant_type", "authorization_code")
form.Set("redirect_uri", f.RedirectUri(r))
form.Set("code", code)
res, err := http.PostForm(fw.TokenURL.String(), form)
if err != nil {
return "", err
}
var token Token
defer res.Body.Close()
err = json.NewDecoder(res.Body).Decode(&token)
return token.Token, err
}
// Get user with token
type User struct {
Id string `json:"id"`
Email string `json:"email"`
Verified bool `json:"verified_email"`
Hd string `json:"hd"`
}
func (f *ForwardAuth) GetUser(token string) (User, error) {
var user User
client := &http.Client{}
req, err := http.NewRequest("GET", fw.UserURL.String(), nil)
if err != nil {
return user, err
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := client.Do(req)
if err != nil {
return user, err
}
defer res.Body.Close()
err = json.NewDecoder(res.Body).Decode(&user)
return user, err
}
// Utility methods
// Get the redirect base
func (f *ForwardAuth) RedirectBase(r *http.Request) string {
proto := r.Header.Get("X-Forwarded-Proto")
host := r.Header.Get("X-Forwarded-Host")
// Direct mode
if f.Direct {
proto = "http"
host = r.Host
}
return fmt.Sprintf("%s://%s", proto, host)
}
// Return url
func (f *ForwardAuth) ReturnUrl(r *http.Request) string {
path := r.Header.Get("X-Forwarded-Uri")
// Testing
if f.Direct {
path = r.URL.String()
}
return fmt.Sprintf("%s%s", f.RedirectBase(r), path)
}
// Get oauth redirect uri
func (f *ForwardAuth) RedirectUri(r *http.Request) string {
return fmt.Sprintf("%s%s", f.RedirectBase(r), f.Path)
}
// Cookie methods
// Create an auth cookie
func (f *ForwardAuth) MakeCookie(r *http.Request, email string) *http.Cookie {
expires := f.cookieExpiry()
mac := f.cookieSignature(r, email, fmt.Sprintf("%d", expires.Unix()))
value := fmt.Sprintf("%s|%d|%s", mac, expires.Unix(), email)
return &http.Cookie{
Name: f.CookieName,
Value: value,
Path: "/",
Domain: f.cookieDomain(r),
HttpOnly: true,
Secure: f.CookieSecure,
Expires: expires,
}
}
// Make a CSRF cookie (used during login only)
func (f *ForwardAuth) MakeCSRFCookie(r *http.Request, nonce string) *http.Cookie {
return &http.Cookie{
Name: f.CSRFCookieName,
Value: nonce,
Path: "/",
Domain: f.cookieDomain(r),
HttpOnly: true,
Secure: f.CookieSecure,
Expires: f.cookieExpiry(),
}
}
// Create a cookie to clear csrf cookie
func (f *ForwardAuth) ClearCSRFCookie(r *http.Request) *http.Cookie {
return &http.Cookie{
Name: f.CSRFCookieName,
Value: "",
Path: "/",
Domain: f.cookieDomain(r),
HttpOnly: true,
Secure: f.CookieSecure,
Expires: time.Now().Local().Add(time.Hour * -1),
}
}
// Validate the csrf cookie against state
func (f *ForwardAuth) ValidateCSRFCookie(c *http.Cookie, state string) (bool, string) {
if len(c.Value) != 32 {
return false, ""
}
if len(state) < 34 {
return false, ""
}
// Check nonce match
if c.Value != state[:32] {
return false, ""
}
// Valid, return redirect
return true, state[33:]
}
func (f *ForwardAuth) Nonce() (error, string) {
// Make nonce
nonce := make([]byte, 16)
_, err := rand.Read(nonce)
if err != nil {
return err, ""
}
return nil, fmt.Sprintf("%x", nonce)
}
// Cookie domain
func (f *ForwardAuth) cookieDomain(r *http.Request) string {
host := r.Header.Get("X-Forwarded-Host")
// Direct mode
if f.Direct {
host = r.Host
}
// Remove port for matching
p := strings.Split(host, ":")
// Check if any of the given cookie domains matches
for _, domain := range f.CookieDomains {
if domain.Match(p[0]) {
return domain.Domain
}
}
return p[0]
}
// Create cookie hmac
func (f *ForwardAuth) cookieSignature(r *http.Request, email, expires string) string {
hash := hmac.New(sha256.New, f.CookieSecret)
hash.Write([]byte(f.cookieDomain(r)))
hash.Write([]byte(email))
hash.Write([]byte(expires))
return base64.URLEncoding.EncodeToString(hash.Sum(nil))
}
// Get cookie expirary
func (f *ForwardAuth) cookieExpiry() time.Time {
return time.Now().Local().Add(f.Lifetime)
}
// Cookie Domain
// Cookie Domain
type CookieDomain struct {
Domain string
DomainLen int
SubDomain string
SubDomainLen int
}
func NewCookieDomain(domain string) *CookieDomain {
return &CookieDomain{
Domain: domain,
DomainLen: len(domain),
SubDomain: fmt.Sprintf(".%s", domain),
SubDomainLen: len(domain) + 1,
}
}
func (c *CookieDomain) Match(host string) bool {
// Exact domain match?
if host == c.Domain {
return true
}
// Subdomain match?
if len(host) >= c.SubDomainLen && host[len(host) - c.SubDomainLen:] == c.SubDomain {
return true
}
return false
}

216
main.go Normal file
View File

@ -0,0 +1,216 @@
package main
import (
"fmt"
"time"
"strings"
"net/url"
"net/http"
"github.com/namsral/flag"
"github.com/op/go-logging"
)
// Vars
var fw *ForwardAuth;
var log = logging.MustGetLogger("traefik-forward-auth")
// Primary handler
func handler(w http.ResponseWriter, r *http.Request) {
// Parse uri
uri, err := url.Parse(r.Header.Get("X-Forwarded-Uri"))
if err != nil {
log.Error("Error parsing url")
http.Error(w, "Service unavailable", 503)
return
}
// Direct mode
if fw.Direct {
uri = r.URL
}
// Handle callback
if uri.Path == fw.Path {
handleCallback(w, r, uri.Query())
return
}
c, err := r.Cookie(fw.CookieName)
if err != nil {
// Error indicates no cookie, generate nonce
err, nonce := fw.Nonce()
if err != nil {
log.Error("Error generating nonce")
http.Error(w, "Service unavailable", 503)
return
}
// Set the CSRF cookie
http.SetCookie(w, fw.MakeCSRFCookie(r, nonce))
log.Debug("Set CSRF cookie and redirecting to google login")
// Forward them on
http.Redirect(w, r, fw.GetLoginURL(r, nonce), http.StatusTemporaryRedirect)
return
}
// Validate cookie
valid, email := fw.ValidateCookie(r, c)
if !valid {
http.Error(w, "Not authorized", 401)
return
}
// Validate user
valid = fw.ValidateEmail(email)
if !valid {
http.Error(w, "Not authorized", 401)
return
}
// Valid request
w.WriteHeader(200)
}
// Authenticate user after they have come back from google
func handleCallback(w http.ResponseWriter, r *http.Request, qs url.Values) {
// Check for CSRF cookie
csrfCookie, err := r.Cookie(fw.CSRFCookieName)
if err != nil {
log.Debug("Missing csrf cookie")
http.Error(w, "Not authorized", 401)
return
}
// Validate state
state := qs.Get("state")
valid, redirect := fw.ValidateCSRFCookie(csrfCookie, state)
if !valid && false {
log.Debugf("Invalid oauth state, expected '%s', got '%s'\n", csrfCookie.Value, state)
http.Error(w, "Not authorized", 401)
return
}
// Clear CSRF cookie
http.SetCookie(w, fw.ClearCSRFCookie(r))
// Exchange code for token
token, err := fw.ExchangeCode(r, qs.Get("code"), fw.RedirectUri(r))
if err != nil {
log.Debugf("Code exchange failed with: %s\n", err)
http.Error(w, "Service unavailable", 503)
return
}
// Get user
user, err := fw.GetUser(token)
if err != nil {
log.Debugf("Error getting user: %s\n", err)
return
}
// Generate cookie
http.SetCookie(w, fw.MakeCookie(r, user.Email))
log.Debugf("Generated auth cookie for %s\n", user.Email)
// Redirect
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
}
// Main
func main() {
// Parse options
flag.String(flag.DefaultConfigFlagname, "", "Path to config file")
path := flag.String("url-path", "_oauth", "Callback URL")
lifetime := flag.Int("lifetime", 43200, "Session length in seconds")
clientId := flag.String("client-id", "", "*Google Client ID (required)")
clientSecret := flag.String("client-secret", "", "*Google Client Secret (required)")
cookieName := flag.String("cookie-name", "_forward_auth", "Cookie Name")
cSRFCookieName := flag.String("csrf-cookie-name", "_forward_auth_csrf", "CSRF Cookie Name")
cookieDomainList := flag.String("cookie-domains", "", "Comma separated list of cookie domains") //todo
cookieSecret := flag.String("cookie-secret", "", "*Cookie secret (required)")
cookieSecure := flag.Bool("cookie-secure", true, "Use secure cookies")
domainList := flag.String("domain", "", "Comma separated list of email domains to allow")
direct := flag.Bool("direct", false, "Run in direct mode (use own hostname as oppose to X-Forwarded-Host, used for testing/development)")
flag.Parse()
// Check for show stopper errors
err := false
if *clientId == "" {
err = true
log.Critical("client-id must be set")
}
if *clientSecret == "" {
err = true
log.Critical("client-secret must be set")
}
if *cookieSecret == "" {
err = true
log.Critical("cookie-secret must be set")
}
if err {
return
}
// Parse lists
var cookieDomains []CookieDomain
if *cookieDomainList != "" {
for _, d := range strings.Split(*cookieDomainList, ",") {
cookieDomain := NewCookieDomain(d)
cookieDomains = append(cookieDomains, *cookieDomain)
}
}
var domain []string
if *domainList != "" {
domain = strings.Split(*domainList, ",")
}
// Setup
fw = &ForwardAuth{
Path: fmt.Sprintf("/%s", *path),
PathLen: len(*path) + 1,
Lifetime: time.Second * time.Duration(*lifetime),
ClientId: *clientId,
ClientSecret: *clientSecret,
Scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
LoginURL: &url.URL{
Scheme: "https",
Host: "accounts.google.com",
Path: "/o/oauth2/auth",
},
TokenURL: &url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v3/token",
},
UserURL: &url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v2/userinfo",
},
CookieName: *cookieName,
CSRFCookieName: *cSRFCookieName,
CookieDomains: cookieDomains,
CookieSecret: []byte(*cookieSecret),
CookieSecure: *cookieSecure,
Domain: domain,
Direct: *direct,
}
// Attach handler
http.HandleFunc("/", handler)
log.Notice("Litening on :4181")
log.Notice(http.ListenAndServe(":4181", nil))
}