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.
Zjednodušene pozostáva proces autentifikácie používateľa z nasledovných krokov:
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”.
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:
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:
/
NGINX vykoná autorizačný subrequest na URL /auth
http://fakenetscaler:8888
, čo je host v internej sieti, na ktorom beží autorizačný server.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.
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:
/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/
zobrazí login stránku/
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.
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.