Skip to content

Commit

Permalink
feat(*) add bastrd HTTP proxy
Browse files Browse the repository at this point in the history
  • Loading branch information
rochacon committed Jan 22, 2019
1 parent 6493992 commit ebe81a2
Show file tree
Hide file tree
Showing 3 changed files with 249 additions and 0 deletions.
65 changes: 65 additions & 0 deletions cmd/proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package cmd

import (
"fmt"
"log"
"net/url"

"github.com/rochacon/bastrd/pkg/proxy"

"github.com/urfave/cli"
)

var Proxy = cli.Command{
Name: "proxy",
Usage: "AWS IAM authenticated HTTP proxy.",
Action: proxyMain,
Flags: []cli.Flag{
cli.StringFlag{
Name: "bind",
Usage: "Address to listen for HTTP requests.",
EnvVar: "BIND",
Value: "0.0.0.0:8080",
},
cli.StringFlag{
Name: "secret-key",
Usage: "Cookie/JWT secret key.",
EnvVar: "SECRET_KEY",
},
cli.StringFlag{
Name: "session-cookie-name",
Usage: "Cookie/JWT secret key.",
EnvVar: "SESSION_COOKIE_NAME",
Value: "sessionToken",
},
cli.StringFlag{
Name: "upstream",
Usage: "Upstream URL, may include path.",
EnvVar: "UPSTREAM_URL",
},
},
}

func proxyMain(ctx *cli.Context) error {
secretKey := ctx.String("secret-key")
if secretKey == "" {
return fmt.Errorf("Secret key is required.")
}
sessionCookieName := ctx.String("session-cookie-name")
if sessionCookieName == "" {
return fmt.Errorf("Session cookie name cant be empty.")
}
upstreamUrl := ctx.String("upstream")
upstream, err := url.Parse(upstreamUrl)
if err != nil {
return fmt.Errorf("Could not parse upstream: %s", err)
}
log.Printf("Upstream: %s", upstream)
srv := &proxy.Server{
Addr: ctx.String("bind"),
SecretKey: []byte(secretKey),
SessionCookieName: sessionCookieName,
Upstream: upstream,
}
return srv.ListenAndServe()
}
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func main() {
app.Commands = []cli.Command{
cmd.AuthorizedKeys,
cmd.PAM,
cmd.Proxy,
cmd.Sync,
cmd.Toolbox,
}
Expand Down
183 changes: 183 additions & 0 deletions pkg/proxy/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package proxy

import (
"context"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"time"

"github.com/rochacon/bastrd/pkg/auth"

jwt "github.com/dgrijalva/jwt-go"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

// Server implements a simple reverse proxy server authenticating on AWS IAM
type Server struct {
Addr string
SecretKey []byte
SessionCookieName string
Upstream *url.URL
}

// ListenAndServer starts the HTTP server.
// This server respects SIGINT and will gracefully shutdown.
func (s *Server) ListenAndServe() error {
mux := http.NewServeMux()
mux.HandleFunc("/", s.ServeHTTP)
mux.HandleFunc("/healthz", s.Health)
mux.HandleFunc("/login", s.Login)
mux.HandleFunc("/logout", s.Logout)
mux.Handle("/metrics", promhttp.Handler())
log.Println("Listening on", s.Addr)
drained := make(chan error)
sigint := make(chan os.Signal)
signal.Notify(sigint, os.Interrupt)
srv := &http.Server{
Addr: s.Addr,
Handler: mux,
}
go func() {
<-sigint
log.Println("Received SIGINT, draining connection")
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
drained <- srv.Shutdown(ctx)
}()
err := srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
return err
}
err = <-drained
log.Printf("Done")
return err
}

// Health returns a successful health check
func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}

// write mainServeHTTP that validates token and route to appropriate serve method
// valid token: proxy to upstream.
// invalid token redirect to login
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// check jwt in cookie, if good call proxy
sessionCookie, err := r.Cookie(s.SessionCookieName)
if err != nil {
http.Redirect(w, r, "/login?error=invalid_cookie", 302)
return
}
tkn, err := s.jwtParse(sessionCookie.Value)
if err != nil {
http.Redirect(w, r, "/login?error=invalid_token", 302)
return
}
log.Printf("Proxying user %q %q %q", tkn["username"], r.Method, r.URL)
s.Proxy(w, r)
}

// proxy request to upstream with net/http/httputil.SingleHostReverseProxy
func (s *Server) Proxy(w http.ResponseWriter, r *http.Request) {
p := httputil.NewSingleHostReverseProxy(s.Upstream)
url := r.URL
url.Host = s.Upstream.Host
defer r.Body.Close()
req, _ := http.NewRequest(r.Method, url.String(), r.Body)
p.ServeHTTP(w, req)
}

// login validates basic auth of username and secret+mfa on AWS IAM and sets cookie with session jwt
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", "Basic realm=\"Provide your credentials\"")
http.Error(w, "Unauthorized", 401)
return
}
lenPassword := len(password)
if lenPassword < 7 {
w.Header().Set("WWW-Authenticate", "Basic realm=\"Invalid credentials\"")
http.Error(w, "Unauthorized", 401)
return
}
expiration := time.Duration(time.Hour * 2)
secretKey, mfaToken := password[:lenPassword-6], password[lenPassword-6:]
_, err := auth.NewSessionCredentials(username, secretKey, mfaToken, expiration)
if err != nil {
log.Printf("Failed authentication for %q: %s", username, err)
w.Header().Set("WWW-Authenticate", "Basic realm=\"Invalid credentials\"")
http.Error(w, "Unauthorized", 401)
return
}
jwtToken, err := s.jwtNew(username, expiration)
if err != nil {
log.Printf("Unexpected error while authenticating %q: %s", username, err)
http.Error(w, fmt.Sprintf("Unexpected error: %s", err), 500)
return
}
http.SetCookie(w, &http.Cookie{
Name: s.SessionCookieName,
Value: jwtToken,
Path: "/",
MaxAge: int(expiration.Seconds()),
HttpOnly: true,
Secure: true,
})
http.Redirect(w, r, "/", 302)
}

// logout kills cookie and redirect to /
func (s *Server) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: s.SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
})
http.Redirect(w, r, "/", 302)
}

// jwtNew create a new JWT for a user
func (s *Server) jwtNew(username string, expires time.Duration) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": username,
"exp": time.Now().Add(expires).Unix(),
})
tokenString, err := token.SignedString(s.SecretKey)
if err != nil {
return "", err
}
return tokenString, nil
}

// jwtParse takes a token string and a function for looking up the key. The latter is especially
// useful if you use multiple keys for your application. The standard is to use 'kid' in the
// head of the token to identify which key to use, but the parsed token (head and claims) is provided
// to the callback, providing flexibility.
func (s *Server) jwtParse(jwtToken string) (jwt.MapClaims, error) {
token, err := jwt.Parse(jwtToken, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return s.SecretKey, nil
})
if err != nil {
return nil, fmt.Errorf("Invalid token: %s", err)
}
if !token.Valid {
return nil, fmt.Errorf("Invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || claims.Valid() != nil {
return nil, fmt.Errorf("Invalid token contents")
}
return claims, nil
}

0 comments on commit ebe81a2

Please sign in to comment.