Použitie NGINX Auth Request Modulu

V tomto poste popíšem jednu zaujímavú požiadavku, ktorú sme v minulosti riešili na jednom z projektov. Síce ide o starší projekt, myslím si však, že problém je stále zaujímavý.

Poďme si však najskôr predstaviť situáciu. Zákazník má existujúcu webovú aplikáciu, ktorá je spolu s celou HW infraštruktúrou hostovaná v dedikovanom datacentre. Súčasťou tejto infraštruktúry je aj Citrix NetScaler, čo je v princípe HW Load Balancer a Reverzný Proxy s nejakými fíčurami navyše. Jednou z týchto fíčur je aj možnosť použitia NetScaleru ako autentifikačného mechanizmu, t.j. NetScaler vpustí iba autentifikovaných používateľov. Webová aplikácia zákazníka je však iba jednou z mnohých aplikácií, ktoré spolu tvoria komplexný systém, sú spoločne hostované v rovnakom datacentre a zdieľajú rovnakých doménových používateľov, t.j. doménový používateľ sa prihlasuje do všetkých aplikácií prostredníctvom rovnakého prihlasovacieho mena a hesla. Každý doménový používateľ je potom v rámci každej aplikácie mapovaný na aplikačného používateľa. NetScaler je teda v našom prípade akási autentifikačná brána. Situácia je schematicky znázornená na nasledovnom obrázku.

Principipiálna schéma autentifikácie využitím NetScaler-u

Zjednodušene pozostáva proces autentifikácie používateľa z nasledovných krokov:

  1. HTTP GET https://protected-resource.example.redbyte.eu
  2. NetScaler zistí, že používateľ nie je autentifikovaný, redirectne (HTTP 302) na Login Page
  3. HTTP POST Login Page
  4. Proces autentifikácie používateľa voči Active Directory
  5. Redirect (HTTP 302) na pôvodnú destináciu (https://protected-resource.example.redbyte.eu)
  6. HTTP GET https://protected-resource.example.redbyte.eu
  7. Proxy na backend server, ktorý z preposlanej HTTP hlavičky vie, o ktorého doménového používateľa sa jedná a stotožní ho na aplikačného používateľa

Problémom takéhoto setupu je však jeho testovateľnosť. Zákazník má niekoľko svojich vlastných, vývojových a testovacích prostredí. Zavádzať NetScaler do týchto prostredí by bol overkill, nerátajúc manažment doménových používateľov pre všetky prostredia. Požiadavkou zákazníka preto bolo nejakým spôsobom “obísť” NetScaler a celú zložitosť konfigurácie a manažmentu používateľov bez zmeny kódu alebo konfigurácie aplikácie s tým, že to “celé musí vyzerať a správať sa akokeby tam NetScaler bol”.

Zoznámte sa s ngx_http_auth_request_module

Prvým riešením, ktoré nás napadlo, bolo použiť ako HTTP Load Balancer excelentný HAProxy a pred neho postaviť vlastný autentifikačný mechanizmus, nazvime ho ho FakeNetScaler (v podstate reverzný proxy server). To by znamenalo, že každý HTTP request by bol spracovávaný dvoma reverznými proxy servermi. Iste musí existovať priamočiarejšie a jednoduchšie riešenie.

Týmto riešením je použitie HTTP Servera NGINX spolu s rozšírením ngx_http_auth_request_module. Keď si prečítate dokumentáciu k tomuto modulu, zistíte že implementuje klientskú autorizáciu na základe subrequestu. Čo to presne znamená? Princíp je pomerne jednoduchý - keď spravíte HTTP request na chránenú URL, NGINX vykoná interný subrequest na definovanú autorizačnú URL. Pokiaľ je výsledkom subrequestu kód HTTP 2xx, NGINX váš pôvodný HTTP request proxuje na backend server. Pokiaľ je výsledkom subrequestu HTTP kód 401 alebo 403, prístup k backend serveru je zamietnutý. Vhodnou konfiguráciou NGINX-u ale môžete browser pri kódoch 401 alebo 403 presmerovať napr. na login stránku, kde sa používateľ autentifikuje a následne je presmerovaný do pôvodnej destinácie (tu sa celý proces s autorizačným subrequestom opakuje, ale ten už vráti kód HTTP 200 a váš HTTP request je proxovaný na backend server).

Samozrejme NGINX poskytuje iba mechanizmus ako uvedené dosiahnuť - samotný autorizačný server si musíte vyrobiť na mieru konkrétnej situácie sami. V našom prípade je autorizačným serverom FakeNetscaler (“akože” NetScaler). K tomuto sa vrátim o chvíľu, poďme sa teraz pozriet ako by vyzeral popísaný proces schematicky:

Principipiálna schéma autentifikácie využitím NGINX a modulu ngx_http_auth_request_module

  1. HTTP GET https://protected-resource.example.redbyte.eu
  2. NGINX zašle autorizačný subrequest na FakeNetScaler
  3. Používateľ nie je zatiaľ autentifikovaný, a preto FakeNetScaler vráti HTTP kód 401
  4. NGINX redirectne browser (HTTP 302) na Login Page
  5. Používateľ zadá prihlasovacie údaje a vykoná HTTP POST login formulára na FakeNetScaler
  6. Prihlasovacie údaje sú v poriadku, FakeNetScaler vráti browseru cookie s obsahom “používateľ XXX je autentifikovaný” a redirectne browser (HTTP 302) do pôvodnej destinácie
  7. HTTP GET pôvodnej destinácie
  8. NGINX zašle autorizačný subrequest na FakeNetScaler
  9. FakeNetscaler sparsuje obsah cookie a zistí, že používateľ je autentifikovaný, ako odpoveď na subrequest teda vráti HTTP kód 200
  10. Proxy na backend server, ktorý z preposlanej HTTP hlavičky vie, o ktorého doménového používateľa ide a stotožní ho na aplikačného používateľa

Na prvý pohľad sa zdá, že toto riešenie je ešte zložitejšie ako pôvodný NetScaler, pravdou však je, že do tohto riešenia vidíme a je popísané ako biela skrinka. Naopak, v prípade NetScalera som proces popísal ako čiernu skrinku hlavne v bodoch 3., 4. a 5.

Principiálne a procesne je teda použitie ngx_http_auth_request_module jasné, poďme sa pozrieť ako môže vyzerať vzorová NGINX konfigurácia. Najskôr pre doménu protected-resource.example.redbyte.eu:

 1server {
 2  listen   443 ssl;
 3  server_name protected-resource.example.redbyte.eu;
 4
 5  # ssl and server configuration left out
 6
 7  location / {
 8    auth_request /auth;
 9    error_page 401 = @error401;
10
11    auth_request_set $user $upstream_http_x_forwarded_user;
12    proxy_set_header X-Forwarded-User $user;
13    proxy_pass http://protected-resource:8080;
14  }
15
16  location /auth {
17    internal;
18    proxy_set_header Host $host;
19    proxy_pass_request_body off;
20    proxy_set_header Content-Length "";
21    proxy_pass http://fakenetscaler:8888;
22  }
23
24  location @error401 {
25    add_header Set-Cookie "NSREDIRECT=$scheme://$http_host$request_uri;Domain=.example.redbyte.eu;Path=/";
26    return 302 https://fakenetscaler.example.redbyte.eu;
27  }
28
29}

V konfigurácii domény protected-resource.example.redbyte.eu sú dôležité hlavne riadky:

  • 8 - kde hovoríme, že pre všetky URL začínajúce na / NGINX vykoná autorizačný subrequest na URL /auth
  • 9 - že v prípade HTTP kódu 401 má prísť k presmerovaniu na login stránku
  • 11 - do premennej $user sa nastaví hodnota HTTP hlavičky X-Forwarded-User, ktorú nastaví autorizačný server
  • 12 - hodnota premennej $user sa pošle ďalej v hlavičke X-Forwarded-User na backend server
  • 16 - kde je definovaný samotný autorizačný subrequest. Ako vidno, v našom prípade ide o reverzný proxy na http://fakenetscaler:8888, čo je host v internej sieti, na ktorom beží autorizačný server.
  • 25 - je nasetovanie Cookie za účelom odpamätania si pôvodnej destinácie, teda URL kam chcel používateľ ísť predtým, než bol presmerovaný na login stránku (použitie tejto Cookie uvidíme neskôr)
  • 26 - je HTTP 302 redirect na login stránku servírovanú z autorizačného servera. V tomto prípade musíme použiť plné doménové meno (aj so správnou schémou), pretože používateľov browser by neresolvol interné hostnames.

NGINX konfigurácia pre autorizačnú doménu fakenetscaler.example.redbyte.eu:

 1server {
 2  listen   443 ssl;
 3  server_name fakenetscaler.example.redbyte.eu;
 4
 5  # ssl and server configuration left out
 6
 7  location / {
 8        proxy_set_header Host $http_host;
 9        proxy_pass http://fakenetscaler:8888;
10  }
11}

Ako vidno z konfigurácie, opäť ide o reverzný proxy server na http://fakenetscaler:8888, čo je host v internej sieti, na ktorom beží autorizačný server.

FakeNetScaler

Doteraz sme sa hrali iba s konfiguráciou NGINX servera, poďme sa preto pozrieť ako bude vyzerať samotný autorizačný server - FakeNetScaler. Ako som už spomínal, NGINX poskytuje iba rámec na custom autorizáciu a samotný autorizačný server si musíme napísať sami, presne na mieru a podľa zadania zákazníka. Rozoberme si ale požiadavky na FakeNetScaler samostatne:

  • musí vedieť odpovedať na HTTP GET /auth request a na základe Cookie rozhodnúť, či je používateľ autentifikovaný alebo nie. V prípade že je, odpovie kódom HTTP 200, v prípade že nie, kódom HTTP 401
  • na HTTP GET / zobrazí login stránku
  • na HTTP POST / submitne login formulár. V prípade, že používateľ zadal správne prihlasovacie meno a heslo, založí Cookie s informáciou, že používateľ je autentifikovaný a presmeruje ho do pôvodnej destinácie na základe informácie uloženej v Cookie. V prípade, že používateľ nezadal správne prihlasovacie údaje, znova sa zobrazí login stránka s popisom chyby.

Z popisu ide o naozaj jednoduchú službu a my ju preto implementuje v programovacom jazyku Go, ktorý už v základnej knižnici obsahuje HTTP server. Nemusíme preto nikde nič deployovať a komplikovať veci, ako v prípade Java-ovského servlet kontajnera.

Posúďte sami, toto je kompletný kód FakeNetScaler servera:

  1package main
  2
  3import (
  4	"flag"
  5	"fmt"
  6	"github.com/BurntSushi/toml"
  7	"github.com/codegangsta/negroni"
  8	"github.com/gorilla/securecookie"
  9	"github.com/julienschmidt/httprouter"
 10	"gopkg.in/unrolled/render.v1"
 11	"log"
 12	"net/http"
 13	"time"
 14)
 15
 16var (
 17	nsCookieName         = "NSLOGIN"
 18	nsCookieHashKey      = []byte("SECURE_COOKIE_HASH_KEY")
 19	nsRedirectCookieName = "NSREDIRECT"
 20	cfg                  config
 21)
 22
 23type config struct {
 24	// e.g. https://protected-resource.example.redbyte.eu
 25	DefaultRedirectUrl string
 26	// shared password
 27	Password string
 28	// shared domain prefix between protected resource and auth server
 29	// e.g. .example.redbyte.eu (note the leading dot)
 30	Domain string
 31}
 32
 33func main() {
 34
 35	// configuration
 36	port := flag.Int("port", 8888, "listen port")
 37	flag.Parse()
 38	var err error
 39	if cfg, err = loadConfig("config.toml"); err != nil {
 40		log.Fatal(err)
 41	}
 42
 43	// template renderer
 44	rndr := render.New(render.Options{
 45		Directory:     "templates",
 46		IsDevelopment: false,
 47	})
 48
 49	// router
 50	router := httprouter.New()
 51	router.GET("/", indexHandler(rndr))
 52	router.POST("/", loginHandler(rndr))
 53	router.GET("/auth", authHandler)
 54
 55	// middleware and static content file server
 56	n := negroni.New(negroni.NewRecovery(), negroni.NewLogger(),
 57		&negroni.Static{
 58			Dir:    http.Dir("public"),
 59			Prefix: ""})
 60	n.UseHandler(router)
 61
 62	n.Run(fmt.Sprintf(":%d", *port))
 63}
 64
 65func authHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
 66	var s = securecookie.New(nsCookieHashKey, nil)
 67	// get the cookie from the request
 68	if cookie, err := r.Cookie(nsCookieName); err == nil {
 69		value := make(map[string]string)
 70		// try to decode it
 71		if err = s.Decode(nsCookieName, cookie.Value, &value); err == nil {
 72			// if if succeeds set X-Forwarded-User header and return HTTP 200 status code
 73			w.Header().Add("X-Forwarded-User", value["user"])
 74			w.WriteHeader(http.StatusOK)
 75			return
 76		}
 77	}
 78
 79	// otherwise return HTTP 401 status code
 80	http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 81}
 82
 83func indexHandler(render *render.Render) httprouter.Handle {
 84	return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
 85		// just render the login page
 86		render.HTML(w, http.StatusOK, "index", nil)
 87	}
 88}
 89
 90func loginHandler(render *render.Render) httprouter.Handle {
 91	return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
 92		login := r.PostFormValue("login")
 93		passwd := r.PostFormValue("passwd")
 94
 95		var errorMessage = false
 96
 97		// nothing fancy here, it is just a demo so every user has the same password
 98		// and if it doesn't match render the login page and present user with error message
 99		if login == "" || passwd != cfg.Password {
100			errorMessage = true
101			render.HTML(w, http.StatusOK, "index", errorMessage)
102		} else {
103			var s = securecookie.New(nsCookieHashKey, nil)
104			value := map[string]string{
105				"user": login,
106			}
107
108			// encode username to secure cookie
109			if encoded, err := s.Encode(nsCookieName, value); err == nil {
110				cookie := &http.Cookie{
111					Name:    nsCookieName,
112					Value:   encoded,
113					Domain:  cfg.Domain,
114					Expires: time.Now().AddDate(1, 0, 0),
115					Path:    "/",
116				}
117				http.SetCookie(w, cookie)
118			}
119
120			// after successful login redirect to original destination (if it exists)
121			var redirectUrl = cfg.DefaultRedirectUrl
122			if cookie, err := r.Cookie(nsRedirectCookieName); err == nil {
123				redirectUrl = cookie.Value
124			}
125			// ... and delete the original destination holder cookie
126			http.SetCookie(w, &http.Cookie{
127				Name:    nsRedirectCookieName,
128				Value:   "deleted",
129				Domain:  cfg.Domain,
130				Expires: time.Now().Add(time.Hour * -24),
131				Path:    "/",
132			})
133
134			http.Redirect(w, r, redirectUrl, http.StatusFound)
135		}
136
137	}
138}
139
140// loads the config file from filename
141// Example config file content:
142/*
143defaultRedirectUrl = "https://protected-resource.example.redbyte.eu"
144password = "shared_password"
145domain = ".example.redbyte.eu"
146*/
147func loadConfig(filename string) (config, error) {
148	var cfg config
149	if _, err := toml.DecodeFile(filename, &cfg); err != nil {
150		return config{}, err
151	}
152	return cfg, nil
153}

Po kompilácií Go kódu vznikne staticky linkovaná binárka (žiadne ďalšie runtime závislosti), ktorú potom spustíme na stroji s hostname fakenetscaler na porte 8888.

Zhodnotenie

V tomto poste sme si ukázali akým spôsobom použiť NGINX a jeho modul ngx_http_auth_request_module, ktorý poskytuje základný rámec na tvorbu custom klientskej autorizácie prostredníctvom jednoduchých a uchopiteľných princípov. Keďže veľmi fandíme programovaciemu jazyku Go, použili sme ho na implementáciu vlastného autorizačného servera implementujúceho tieto princípy.