This document discusses different approaches to building an authentication middleware in Go web applications. It begins with using the standard library, then explores Goji and its request context. It settles on using the x/net/context package and kami router, which allow sharing database connections and authentication objects across requests and tests through the request context. Middleware is defined hierarchically in kami. This approach avoids global variables and simplifies testing.
1 of 33
Downloaded 19 times
More Related Content
神に近づくx/net/context (Finding God with x/net/context)
4. Attempt #1: Standard library
Everyone told me to use the standard library, let's use it.
Index page says hello
Secret message page requires key
func main() {
http.HandleFunc("/", indexHandler)
http.HandleFunc("/secret/message", requireKey(secretMessageHandler))
http.ListenAndServe(":8000", nil)
}
func indexHandler(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "hello world")
}
5. Secret message
func secretMessageHandler(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "42")
}
Here's a way to write middleware with just the standard library:
func requireKey(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.FormValue("key") != "12345" {if r.FormValue("key") != "12345" {
http.Error(w, "bad key", http.StatusForbidden)
return
}
h(w, r)
}
}
In main.go:
http.HandleFunc("/secret/message", requireKey(secretMessageHandler))
6. There's been a change of plans...
We were hard-coding the key, but your boss says now we need to check Redis.
Let's just make our Redis connection a global variable for now...
var redisDB *redis.Client
func main() {
redisDB = ... // set up redis
}
func requireKeyRedis(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.FormValue("key")
userID, err := redisDB.Get("auth:" + key).Result()userID, err := redisDB.Get("auth:" + key).Result()
if key == "" || err != nil {if key == "" || err != nil {
http.Error(w, "bad key", http.StatusForbidden)
return
}
log.Println("user", userID, "viewed message")
h(w, r)
}
}
7. Just one quick addition...
We need to issue temporary session tokens for some use cases, so we need to check if
either a key or a session is provided.
func requireKeyOrSession(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.FormValue("key")
// set key from db if we have a session
if session := r.FormValue("session"); session != "" {if session := r.FormValue("session"); session != "" {
var err error
if key, err = redisDB.Get("session:" + session).Result(); err != nil {if key, err = redisDB.Get("session:" + session).Result(); err != nil {
http.Error(w, "bad session", http.StatusForbidden)
return
}
}
userID, err := redisDB.Get("auth:" + key).Result()
if key == "" || err != nil {
http.Error(w, "bad key", http.StatusForbidden)
return
}
log.Println("user", userID, "viewed message")
h(w, r)
}
}
8. By the way...
Your boss also asks:
Can we also check the X-API-Key header?
Can we restrict certain keys to certain IP addresses?
Can we ...?
There's too much to shove into one middleware: so we make an auth package.
package auth
type Auth struct {
Key string
Session string
UserID string
}
func Check(r *http.Request) (auth Auth, ok bool) {
// lots of complicated checks
}
9. What about Redis?
We need to reference the DB from our new auth package as well.
Should we pass the connection to Check?
func Check(redisDB *redis.Client, r *http.Request) (Auth, bool) { ... }
What happens we need to check MySQL as well?
func Check(redisDB *redis.Client, archiveDB *sql.DB, r *http.Request) (Auth, bool) { ... }
Your boss says MongoDB is web scale, so that gets added too.
func Check(redisDB *redis.Client, archiveDB *sql.DB, mongo *mgo.Session, r *http.Request) (Auth, bool) { ...
This isn't going to work...
10. How about an init method?
Making a global here too?
var redisDB *redis.Client
func Init(r *redis.Client, ...) {
redisDB = r
}
That doesn't solve our arguments problem. Let's shove them in a struct.
package config
type Context struct {
RedisDB *redis.Client
ArchiveDB *sql.DB
...
}
Init with this guy?
auth.Init(appContext)
Who inits who?
What about tests?
11. Just one more thing...
Your boss says it's vital that we log every request now, and include the key and user ID if
possible.
It's easy to write logging middleware, but how can we make our logger aware of our
Auth credentials?
12. Session table
Let's try making a global map of connections to auths.
var authMap map[*http.Request]*auth.Auth
Then populate it during our check.
func requireKeyOrSession(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
...
a, ok := auth.Check(dbContext, r)
authMapMutex.Lock()
authMap[r] = &a
...
}
}
Should work, but will our *http.Requests leak? We need to make sure to clean them up.
What happens when we need to keep track of more than just Auth?
How do we coordinate this data across packages? What about concurrency?
(This is kind of how gorilla/sessions works)
14. Attempt #2: Goji
Goji is a popular web micro-framework. Goji handlers take an extra parameter called
web.C (probably short for Context).
c.Env is a map[interface{}]interface{} for storing arbitrary data — perfect for our auth
token! This used to be a map[string]interface{}, more on this later.
Let's rewrite our auth middleware for Goji:
func requiresKey(c *web.C, h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
a := c.Env["auth"]
if a == nil {
http.Error(w, "bad key", http.StatusForbidden)
return
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
15. Goji groups
We can set up groups of routes:
package main
import (
"github.com/zenazn/goji"
"github.com/zenazn/goji/web"
)
func main() {
...
secretGroup := web.New()
secretGroup.Use(requiresKey)
secretGroup.Get("/secret/message", secretMessageHandler)
goji.Handle("/secret/*", secretGroup)
goji.Serve()
}
This will run our checkAuth for all routes under /secret/.
17. Downside: Goji-flavored context
Let's say we want to re-use our auth package elsewhere, like a batch process.
Do we want to put our database connections in web.C, even if we're not running a web
server? Should all of our internal packages be importing Goji?
package auth
func Check(c web.C, session, key string) bool {
// How do we call this if we're not using goji?
redisDB, _ := c.Env["redis"].(*redis.Client) // kind of ugly...
}
Having to do a type assertion every time we use this DB is annoying. Also, what happens
when some other library wants to use this "redis" key?
18. Downside: Groups need to be set up once, in main.go
Defining middleware for a group is tricky. What happens if you have code like...
package addon
func init() {
goji.Get("/secret/addon", addonHandler) // will secretGroup handle this?
}
Everything works will if your entire app is set up in main.go, but in my experience it's
very finicky and hard to reason about handlers that are set up in other ways.
20. Attempt #3: kami & x/net/context
What is x/net/context?
It's an almost-standard package for sharing context across your entire app.
Includes facilities for setting deadlines and cancelling requests.
Includes a way to store data similar to Goji's web.C.
Immutable, must be replaced to update
Check out this official blog post, which focuses mostly on x/net/context for cancellation:
blog.golang.org/context(https://blog.golang.org/context)
Quick example:
ctx := context.Background() // blank context
ctx = context.WithValue(ctx, "my_key", "my_value")
fmt.Println(ctx.Value("my_key").(string)) // "my_value"
21. kami
kami is a mix of HttpRouter, x/net/context, and Goji, with a very simple middleware
system included.
package main
import (
"fmt"
"net/http"
"github.com/guregu/kami"
"golang.org/x/net/context"
)
func hello(ctx context.Context, w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", kami.Param(ctx, "name"))
}
func main() {
kami.Get("/hello/:name", hello)
kami.Serve()
}
22. Example: sharing DB connections
import "github.com/guregu/db"
I made a simple package for storing DB connections in your context. At Gunosy, we use
something similar. db.OpenSQL() returns a new context containing a named SQL
connection.
func main() {
ctx := context.Background()
mysqlURL := "root:hunter2@unix(/tmp/mysql.sock)/myCoolDB"
ctx = db.OpenSQL(ctx, "main", "mysql", mysqlURL)ctx = db.OpenSQL(ctx, "main", "mysql", mysqlURL)
defer db.Close(ctx) // closes all DB connectionsdefer db.Close(ctx) // closes all DB connections
kami.Context = ctxkami.Context = ctx
kami.Get("/hello/:name", hello)
kami.Serve()
}
kami.Context is our "god context" from which all request contexts are derived.
23. Example: sharing DB connections (2)
Within a request, we use db.SQL(ctx, name) to retrieve the connection.
func hello(ctx context.Context, w http.ResponseWriter, r *http.Request) {
mainDB := db.SQL(ctx, "main") // *sql.DBmainDB := db.SQL(ctx, "main") // *sql.DB
var greeting string
mainDB.QueryRow("SELECT content FROM greetings WHERE name = ?", kami.Param(ctx, "name")).
Scan(&greeting)
fmt.Fprintf(w, "Hello, %s!", greeting)
}
24. Tests
For tests, you can put a mock DB connection in your context.
main_test.go:
import _ "github.com/mycompany/testhelper"
testhelper/testhelper.go:
import (
"github.com/guregu/db"
"github.com/guregu/kami"
_ "github.com/guregu/mogi"
)
func init() {
ctx := context.Background()
// use mogi for tests
ctx = db.OpenSQL("main", "mogi", "")
kami.Context = ctx
}
25. How does it work?
Because context.Value() takes an interface{}, we can use unexported type as the key to
"protect" it. This way, other packages can't screw with your data. In order to interact with
a database, you have to use the exported functions like OpenSQL, and Close.
package db
import (
"database/sql"
"golang.org/x/net/context"
)
type sqlkey string // lowercase!
// SQL retrieves the *sql.DB with the given name or nil.
func SQL(ctx context.Context, name string) *sql.DB {
db, _ := ctx.Value(sqlkey(name)).(*sql.DB)
return db
}
BTW: This is why Goji switched its web.C from a map[string]interface{} to
map[interface{}]interface{}.
26. Middleware
kami has no concept of middleware "groups". Middleware is strictly hierarchical.
For example, a request for /secret/message would run the middleware registered under
the following paths in order:
/
/secret/
/secret/message
This means that you can define your paths anywhere and still get predictable
middleware behavior.
kami.Use("/secret/", requireKey)
27. Middleware (2)
kami.Middleware is defined as:
type Middleware func(context.Context, http.ResponseWriter, *http.Request) context.Context
The context you return will be used for the next middleware or handler.
Unlike Goji, you don't have control of how the next handler will be called. But, you can
return nil to halt the execution chain.
28. Middleware (3)
import "github.com/mycompany/auth"
func init() {
kami.Use("/", doAuth)
kami.Use("/secret/", requiresKey)
}
// doAuth returns a new context with the appropiate auth object inside
func doAuth(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context {
if a, err := auth.ByKey(ctx, r.FormValue("key")); err == nil {
// put auth object in context
ctx = auth.NewContext(ctx, a)
}
return ctx
}
// requiresKey stops the request if we don't have an auth object
func requiresKey(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context {
if _, ok := auth.FromContext(ctx); !ok {
http.Error(w, "bad key", http.StatusForbidden)
return nil // stop request
}
return ctx
}
29. Hooks
kami provides special hooks for logging and recovering from panics, kami.LogHandler
and kami.PanicHandler.
Handling panics.
kami.PanicHandler = func(ctx context.Context, w http.ResponseWriter, r *http.Request) {
err := kami.Exception(ctx)
a, _ := auth.FromContext(ctx)
log.Println("panic", err, a)
}
Logging request statuses. Notice how the function signature is different, it takes a writer
proxy that includes the status code.
kami.LogHandler = func(ctx context.Context, w mutil.WriterProxy, r *http.Request) {
a, _ := auth.FromContext(ctx)
log.Println("access", w.Status(), r.URL.Path, "from:", a.Key, a.UserID)
}
LogHandler will run after PanicHandler, unless LogHandler is the one panicking.
30. Graceful
This is the "Goji" part of kami. Literally copy and pasted from Goji.
kami.Serve() // works *exactly* like goji.Serve()
Supports Einhorn for graceful restarts.
Thank you, Goji.
31. Downsides
kami isn't perfect. It is rather inflexible and may not fit your needs.
You can't define separate groups of middleware, or separate groups of handlers,
everything is global. You could mount kami.Handler() outside of "/" and use another
router...
You can't register middleware under wildcard paths: kami.Use("/user/:id/profile",
middleware) won't work. Register it under /user/ and do your best.
I will probably fix these issues eventually. Might have to fork HttpRouter...
Pull requests are always welcome.
32. Production ready!
We use kami to power the Gunosy API and it works just fine!
Switching to x/net/context eliminates nearly all global variables.
No more somepkg.Init() madness.
Easy to test: just put mocks inside your context.
Check it out!