diff options
author | Mike Crute <mike@crute.us> | 2017-08-25 01:09:39 +0000 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2017-08-25 01:09:39 +0000 |
commit | b602fac5decc5daab15af6abf2dc6dfeb649c1d5 (patch) | |
tree | 88c677c9dd56d0150b127a8c3d4e641484a44329 | |
download | oidc_proxy-b602fac5decc5daab15af6abf2dc6dfeb649c1d5.tar.bz2 oidc_proxy-b602fac5decc5daab15af6abf2dc6dfeb649c1d5.tar.xz oidc_proxy-b602fac5decc5daab15af6abf2dc6dfeb649c1d5.zip |
Initial stub checkin
-rw-r--r-- | main.go | 263 |
1 files changed, 263 insertions, 0 deletions
@@ -0,0 +1,263 @@ | |||
1 | package main | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "crypto/rand" | ||
6 | "encoding/hex" | ||
7 | _ "github.com/dgrijalva/jwt-go" | ||
8 | "log" | ||
9 | "net/http" | ||
10 | "net/http/httputil" | ||
11 | "net/url" | ||
12 | "strings" | ||
13 | ) | ||
14 | |||
15 | const ( | ||
16 | NONCE_SIZE int = 16 | ||
17 | TOKEN_COOKIE_NAME string = "sso_token" | ||
18 | RFP_COOKIE_NAME string = "sso_rfp" | ||
19 | ) | ||
20 | |||
21 | // acr_values can be mfa or selective_mfa (mfa only for external users) | ||
22 | // mfa amr values: | ||
23 | // pas - password | ||
24 | // otp - OTP code | ||
25 | // u2f - U2F code | ||
26 | // mfa - multi-factor | ||
27 | // hrd - hardware OTP device used | ||
28 | // sft - software OTP device used | ||
29 | |||
30 | type ProxyConfig struct { | ||
31 | IDProviderURL string | ||
32 | ClientID string | ||
33 | UpstreamURL string | ||
34 | ListenOn string | ||
35 | TrustedCACert string | ||
36 | RequestMFA bool | ||
37 | AllowedMFAMethods []string // An OR set | ||
38 | RequiredMFAMethods []string // An AND set | ||
39 | reverseProxy *httputil.ReverseProxy | ||
40 | } | ||
41 | |||
42 | func URLMustParse(u string) *url.URL { | ||
43 | o, err := url.Parse(u) | ||
44 | if err != nil { | ||
45 | panic(err) | ||
46 | } | ||
47 | return o | ||
48 | } | ||
49 | |||
50 | func GenerateNonce() (string, error) { | ||
51 | nonce := make([]byte, NONCE_SIZE) | ||
52 | n, err := rand.Read(nonce) | ||
53 | if n != NONCE_SIZE || err != nil { | ||
54 | return "", err | ||
55 | } | ||
56 | return hex.EncodeToString(nonce), nil | ||
57 | } | ||
58 | |||
59 | func CompareUpper(lhs, rhs string) bool { | ||
60 | return strings.ToUpper(lhs) == strings.ToUpper(rhs) | ||
61 | } | ||
62 | |||
63 | // TODO | ||
64 | // Cookie rules | ||
65 | // Secure | ||
66 | // HttpOnly | ||
67 | // Path to / | ||
68 | // Expires to iat in JWT | ||
69 | func SetCookie() { | ||
70 | } | ||
71 | |||
72 | // TODO | ||
73 | // Fetch (connect timeout 1s, read timeout 30s, read size 1M) | ||
74 | func DownloadCertificate() { | ||
75 | } | ||
76 | |||
77 | // TODO | ||
78 | // Fetch (connect timeout 1s, read timeout 30s, read size 1M) | ||
79 | func DownloadCRL() { | ||
80 | } | ||
81 | |||
82 | // TODO | ||
83 | // Cert validation | ||
84 | // Validate cert not in CRL | ||
85 | // Validate cert chains to trusted CA cert (ship with proxy) | ||
86 | // Validate CRL signed by trusted CA | ||
87 | // Cert "Subject CN " must match exactly PKI setting (ex: foo-pki.foo.com) | ||
88 | // Current time bust be within "Validity Not Before" and "Validity Not After" in cert +- 5 minutes | ||
89 | // Cert Key length >= 2048 | ||
90 | // Certificate usage must include "digitalSignature" | ||
91 | func ValidateCertificate() { | ||
92 | } | ||
93 | |||
94 | func MakeClientID(r *http.Request) string { | ||
95 | if strings.Contains(r.Host, ":") { | ||
96 | return r.Host | ||
97 | } | ||
98 | } | ||
99 | |||
100 | // TODO | ||
101 | func RedirectToIDP(w http.ResponseWriter, r *http.Request) { | ||
102 | nonce := GenerateNonce() | ||
103 | nonceh := "" // SHA256 nonce | ||
104 | |||
105 | // Set nonce cookie | ||
106 | |||
107 | req := url.Values{} | ||
108 | req.Add("client_id", "") // fqdn + : + port | ||
109 | req.Add("nonce", nonceh) | ||
110 | req.Add("redirect_uri", "") // Requested URL | ||
111 | req.Add("scope", "openid") | ||
112 | req.Add("response_type", "id_token") | ||
113 | } | ||
114 | |||
115 | // TODO: Remove id_token from URL, set cookie and redirect user to requested URL | ||
116 | func SetTokenCookieAndRedirect(w http.ResponseWriter, r *http.Request, token string) { | ||
117 | } | ||
118 | |||
119 | // TODO | ||
120 | // Fetch cert from x5u URL | ||
121 | // Get CRL from cert, fetch (connect timeout 1s, read timeout 30s, read size 1M) | ||
122 | // | ||
123 | // exp claim has passed +- 5 minutes | ||
124 | // iat claim is greater than 24 hours +- 5 minutes | ||
125 | // aud claim is exact match for client_id | ||
126 | // iss claim is exact match for idp (ex: foo.example.com | ||
127 | // if other aud claims validate that they are known | ||
128 | // nonce in JWT must be SHA256 of rfp cookie value | ||
129 | // Validate cert | ||
130 | // alg jwt header must be one of [PS256, PS385, PS512] | ||
131 | // typ jwt header must be JWS | ||
132 | // validate jwt signature | ||
133 | // validate amr claim contains requested acr values (selective_mfa will be just mfa) | ||
134 | // validate acr claim is the same as requested acr_values | ||
135 | func ValidateJWT(jwt, rfp string) bool { | ||
136 | return true | ||
137 | } | ||
138 | |||
139 | // TODO | ||
140 | func GetJWTSubject(jwt string) string { | ||
141 | return "" | ||
142 | } | ||
143 | |||
144 | func RequestHasForwardedUser(w http.ResponseWriter, r *http.Request) bool { | ||
145 | if _, ok := r.Header["X-Forwarded-User"]; ok { | ||
146 | log.Printf("ERROR: Request contains X-Forwarded-For header") | ||
147 | http.Error(w, "Bad Request", http.StatusBadRequest) | ||
148 | return true | ||
149 | } else { | ||
150 | return false | ||
151 | } | ||
152 | } | ||
153 | |||
154 | func RequestIsOverSecureChannel(w http.ResponseWriter, r *http.Request) { | ||
155 | if https, ok := r.Header["X-Forwarded-Proto"]; !ok || len(https) != 1 { | ||
156 | log.Printf("ERROR: Request does not contain X-Forwarded-Proto header") | ||
157 | http.Error(w, "Bad Request", http.StatusBadRequest) | ||
158 | return false | ||
159 | } | ||
160 | |||
161 | if !CompareUpper(https[0], "HTTPS") { | ||
162 | log.Printf("ERROR: Request is not over HTTPS") | ||
163 | http.Error(w, "Bad Request", http.StatusBadRequest) | ||
164 | return false | ||
165 | } | ||
166 | |||
167 | return true | ||
168 | } | ||
169 | |||
170 | // TODO | ||
171 | // - Validate Hostname header == known hostname | ||
172 | func AuthProxyController(w http.ResponseWriter, r *http.Request) { | ||
173 | proxy := r.Context().Value("ProxyConfig").(*ProxyConfig).reverseProxy | ||
174 | |||
175 | // Order matters in these checks! | ||
176 | if RequestHasForwardedUser(w, r) { | ||
177 | return | ||
178 | } | ||
179 | |||
180 | if !RequestIsOverSecureChannel(w, r) { | ||
181 | return | ||
182 | } | ||
183 | |||
184 | if CompareUpper(r.Method, "OPTIONS") { | ||
185 | proxy.ServeHTTP(w, r) | ||
186 | return | ||
187 | } | ||
188 | |||
189 | rfpc, err := r.Cookie(RFP_COOKIE_NAME) | ||
190 | if err != nil { | ||
191 | log.Printf("ERROR: No rfp cookie") | ||
192 | RedirectToIDP(w, r) | ||
193 | return | ||
194 | } | ||
195 | |||
196 | token := r.URL.Query().Get("id_token") | ||
197 | if token != "" && ValidateJWT(rfpc.Value, token) { | ||
198 | SetTokenCookieAndRedirect(w, r, token) | ||
199 | return | ||
200 | } | ||
201 | |||
202 | tokenc, err := r.Cookie(TOKEN_COOKIE_NAME) | ||
203 | if err != nil { | ||
204 | log.Printf("ERROR: No token cookie") | ||
205 | RedirectToIDP() | ||
206 | return | ||
207 | } | ||
208 | |||
209 | if !ValidateJWT(tokenc.Value, rfpc.Value) { | ||
210 | log.Printf("ERROR: Token is invalid") | ||
211 | RedirectToIDP() | ||
212 | return | ||
213 | } | ||
214 | |||
215 | r.Header["X-Forwarded-User"] = []string{GetJWTSubject(tokenc.Value)} | ||
216 | |||
217 | proxy.ServeHTTP(w, r) | ||
218 | } | ||
219 | |||
220 | // Remove token and rfp cookies and redirect user to root of domain | ||
221 | func LogoutController(w http.ResponseWriter, r *http.Request) { | ||
222 | http.SetCookie(w, &http.Cookie{ | ||
223 | Name: TOKEN_COOKIE_NAME, | ||
224 | Value: "", | ||
225 | MaxAge: 0, | ||
226 | }) | ||
227 | |||
228 | http.SetCookie(w, &http.Cookie{ | ||
229 | Name: TOKEN_COOKIE_NAME, | ||
230 | Value: "", | ||
231 | MaxAge: 0, | ||
232 | }) | ||
233 | |||
234 | http.Redirect(w, r, "/", http.StatusFound) | ||
235 | } | ||
236 | |||
237 | // TODO | ||
238 | func parseConfig() *ProxyConfig { | ||
239 | return &ProxyConfig{ | ||
240 | IDProviderURL: "", | ||
241 | ClientID: "", | ||
242 | UpstreamURL: "http://localhost:9991/", | ||
243 | ListenOn: ":9992", | ||
244 | TrustedCACert: "", | ||
245 | } | ||
246 | } | ||
247 | |||
248 | func main() { | ||
249 | cfg := parseConfig() | ||
250 | cfg.reverseProxy = httputil.NewSingleHostReverseProxy(URLMustParse(cfg.UpstreamURL)) | ||
251 | |||
252 | http.HandleFunc("/.oidc/logout", func(w http.ResponseWriter, r *http.Request) { | ||
253 | LogoutController(w, | ||
254 | r.WithContext(context.WithValue(r.Context(), "ProxyConfig", cfg))) | ||
255 | }) | ||
256 | |||
257 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
258 | AuthProxyController(w, | ||
259 | r.WithContext(context.WithValue(r.Context(), "ProxyConfig", cfg))) | ||
260 | }) | ||
261 | |||
262 | log.Fatal(http.ListenAndServe(cfg.ListenOn, nil)) | ||
263 | } | ||