auth

Paddy 2014-12-06 Parent:0a6e3f14b054 Child:4cb65cf90217

83:8630b108ce35 Go to Latest

auth/oauth2.go

Jot out plans for refactoring GetTokenHandler. Basically, the GetTokenHandler we have set up is too specific and not extensible enough. We should treat grant types as pluggable and separate them a bit more from token issuance. The comment has a few ideas about how that could be achieved.

History
1 package auth
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "errors"
8 "html/template"
9 "log"
10 "net/http"
11 "net/url"
12 "time"
14 "code.secondbit.org/pass"
15 "code.secondbit.org/uuid"
17 "github.com/gorilla/mux"
18 )
20 const (
21 authCookieName = "auth"
22 defaultGrantExpiration = 600 // default to ten minute grant expirations
23 getGrantTemplateName = "get_grant"
24 )
26 var (
27 // ErrNoAuth is returned when an Authorization header is not present or is empty.
28 ErrNoAuth = errors.New("no authorization header supplied")
29 // ErrInvalidAuthFormat is returned when an Authorization header is present but not the correct format.
30 ErrInvalidAuthFormat = errors.New("authorization header is not in a valid format")
31 // ErrIncorrectAuth is returned when a user authentication attempt does not match the stored values.
32 ErrIncorrectAuth = errors.New("invalid authentication")
33 // ErrInvalidPassphraseScheme is returned when an undefined passphrase scheme is used.
34 ErrInvalidPassphraseScheme = errors.New("invalid passphrase scheme")
35 // ErrNoSession is returned when no session ID is passed with a request.
36 ErrNoSession = errors.New("no session ID found")
37 )
39 type tokenResponse struct {
40 AccessToken string `json:"access_token"`
41 TokenType string `json:"token_type,omitempty"`
42 ExpiresIn int32 `json:"expires_in,omitempty"`
43 RefreshToken string `json:"refresh_token,omitempty"`
44 }
46 type errorResponse struct {
47 Error string `json:"error"`
48 Description string `json:"error_description,omitempty"`
49 URI string `json:"error_uri,omitempty"`
50 }
52 func renderJSONError(enc *json.Encoder, errorType string) {
53 err := enc.Encode(errorResponse{
54 Error: errorType,
55 })
56 if err != nil {
57 // TODO(paddy): log this or something
58 }
59 }
61 func checkCookie(r *http.Request, context Context) (Session, error) {
62 cookie, err := r.Cookie(authCookieName)
63 if err == http.ErrNoCookie {
64 return Session{}, ErrNoSession
65 } else if err != nil {
66 log.Println(err)
67 return Session{}, err
68 }
69 sess, err := context.GetSession(cookie.Value)
70 if err == ErrSessionNotFound {
71 return Session{}, ErrInvalidSession
72 } else if err != nil {
73 return Session{}, err
74 }
75 if !sess.Active {
76 return Session{}, ErrInvalidSession
77 }
78 return sess, nil
79 }
81 func buildLoginRedirect(r *http.Request, context Context) string {
82 if context.loginURI == nil {
83 return ""
84 }
85 uri := *context.loginURI
86 q := uri.Query()
87 q.Set("from", r.URL.String())
88 uri.RawQuery = q.Encode()
89 return uri.String()
90 }
92 func authenticate(user, passphrase string, context Context) (Profile, error) {
93 profile, err := context.GetProfileByLogin(user)
94 if err != nil {
95 if err == ErrProfileNotFound || err == ErrLoginNotFound {
96 return Profile{}, ErrIncorrectAuth
97 }
98 return Profile{}, err
99 }
100 switch profile.PassphraseScheme {
101 case 1:
102 realPass, err := hex.DecodeString(profile.Passphrase)
103 if err != nil {
104 return Profile{}, err
105 }
106 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(profile.Salt))
107 if !pass.Compare(candidate, realPass) {
108 return Profile{}, ErrIncorrectAuth
109 }
110 default:
111 return Profile{}, ErrInvalidPassphraseScheme
112 }
113 return profile, nil
114 }
116 func wrap(context Context, f func(w http.ResponseWriter, r *http.Request, context Context)) http.Handler {
117 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118 f(w, r, context)
119 })
120 }
122 // RegisterOAuth2 adds handlers to the passed router to handle the OAuth2 endpoints.
123 func RegisterOAuth2(r *mux.Router, context Context) {
124 r.Handle("/authorize", wrap(context, GetGrantHandler))
125 r.Handle("/token", wrap(context, GetTokenHandler))
126 }
128 // GetGrantHandler presents and processes the page for asking a user to grant access
129 // to their data. See RFC 6749, Section 4.1.
130 func GetGrantHandler(w http.ResponseWriter, r *http.Request, context Context) {
131 session, err := checkCookie(r, context)
132 if err != nil {
133 if err == ErrNoSession || err == ErrInvalidSession {
134 redir := buildLoginRedirect(r, context)
135 if redir == "" {
136 log.Println("No login URL configured.")
137 w.WriteHeader(http.StatusInternalServerError)
138 context.Render(w, getGrantTemplateName, map[string]interface{}{
139 "internal_error": template.HTML("Missing login URL."),
140 })
141 return
142 }
143 http.Redirect(w, r, redir, http.StatusFound)
144 return
145 }
146 log.Println(err.Error())
147 w.WriteHeader(http.StatusInternalServerError)
148 context.Render(w, getGrantTemplateName, map[string]interface{}{
149 "internal_error": template.HTML(err.Error()),
150 })
151 return
152 }
153 if r.URL.Query().Get("client_id") == "" {
154 w.WriteHeader(http.StatusBadRequest)
155 context.Render(w, getGrantTemplateName, map[string]interface{}{
156 "error": template.HTML("Client ID must be specified in the request."),
157 })
158 return
159 }
160 clientID, err := uuid.Parse(r.URL.Query().Get("client_id"))
161 if err != nil {
162 w.WriteHeader(http.StatusBadRequest)
163 context.Render(w, getGrantTemplateName, map[string]interface{}{
164 "error": template.HTML("client_id is not a valid Client ID."),
165 })
166 return
167 }
168 redirectURI := r.URL.Query().Get("redirect_uri")
169 redirectURL, err := url.Parse(redirectURI)
170 if err != nil {
171 w.WriteHeader(http.StatusBadRequest)
172 context.Render(w, getGrantTemplateName, map[string]interface{}{
173 "error": template.HTML("The redirect_uri specified is not valid."),
174 })
175 return
176 }
177 client, err := context.GetClient(clientID)
178 if err != nil {
179 if err == ErrClientNotFound {
180 w.WriteHeader(http.StatusBadRequest)
181 context.Render(w, getGrantTemplateName, map[string]interface{}{
182 "error": template.HTML("The specified Client couldn’t be found."),
183 })
184 } else {
185 log.Println(err.Error())
186 w.WriteHeader(http.StatusInternalServerError)
187 context.Render(w, getGrantTemplateName, map[string]interface{}{
188 "internal_error": template.HTML(err.Error()),
189 })
190 }
191 return
192 }
193 // whether a redirect URI is valid or not depends on the number of endpoints
194 // the client has registered
195 numEndpoints, err := context.CountEndpoints(clientID)
196 if err != nil {
197 log.Println(err.Error())
198 w.WriteHeader(http.StatusInternalServerError)
199 context.Render(w, getGrantTemplateName, map[string]interface{}{
200 "internal_error": template.HTML(err.Error()),
201 })
202 return
203 }
204 var validURI bool
205 if redirectURI != "" {
206 // BUG(paddy): We really should normalize URIs before trying to compare them.
207 validURI, err = context.CheckEndpoint(clientID, redirectURI)
208 if err != nil {
209 log.Println(err.Error())
210 w.WriteHeader(http.StatusInternalServerError)
211 context.Render(w, getGrantTemplateName, map[string]interface{}{
212 "internal_error": template.HTML(err.Error()),
213 })
214 return
215 }
216 } else if redirectURI == "" && numEndpoints == 1 {
217 // if we don't specify the endpoint and there's only one endpoint, the
218 // request is valid, and we're redirecting to that one endpoint
219 validURI = true
220 endpoints, err := context.ListEndpoints(clientID, 1, 0)
221 if err != nil {
222 log.Println(err.Error())
223 w.WriteHeader(http.StatusInternalServerError)
224 context.Render(w, getGrantTemplateName, map[string]interface{}{
225 "internal_error": template.HTML(err.Error()),
226 })
227 return
228 }
229 if len(endpoints) != 1 {
230 validURI = false
231 } else {
232 u := endpoints[0].URI // Copy it here to avoid grabbing a pointer to the memstore
233 redirectURI = u.String()
234 redirectURL = &u
235 }
236 } else {
237 validURI = false
238 }
239 if !validURI {
240 w.WriteHeader(http.StatusBadRequest)
241 context.Render(w, getGrantTemplateName, map[string]interface{}{
242 "error": template.HTML("The redirect_uri specified is not valid."),
243 })
244 return
245 }
246 scope := r.URL.Query().Get("scope")
247 state := r.URL.Query().Get("state")
248 if r.URL.Query().Get("response_type") != "code" {
249 q := redirectURL.Query()
250 q.Add("error", "invalid_request")
251 q.Add("state", state)
252 redirectURL.RawQuery = q.Encode()
253 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
254 return
255 }
256 if r.Method == "POST" {
257 // BUG(paddy): We need to implement CSRF protection when obtaining a grant code.
258 if r.PostFormValue("grant") == "approved" {
259 code := uuid.NewID().String()
260 grant := Grant{
261 Code: code,
262 Created: time.Now(),
263 ExpiresIn: defaultGrantExpiration,
264 ClientID: clientID,
265 Scope: scope,
266 RedirectURI: r.URL.Query().Get("redirect_uri"),
267 State: state,
268 ProfileID: session.ProfileID,
269 }
270 err := context.SaveGrant(grant)
271 if err != nil {
272 q := redirectURL.Query()
273 q.Add("error", "server_error")
274 q.Add("state", state)
275 redirectURL.RawQuery = q.Encode()
276 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
277 return
278 }
279 q := redirectURL.Query()
280 q.Add("code", code)
281 q.Add("state", state)
282 redirectURL.RawQuery = q.Encode()
283 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
284 return
285 }
286 q := redirectURL.Query()
287 q.Add("error", "access_denied")
288 q.Add("state", state)
289 redirectURL.RawQuery = q.Encode()
290 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
291 return
292 }
293 w.WriteHeader(http.StatusOK)
294 context.Render(w, getGrantTemplateName, map[string]interface{}{
295 "client": client,
296 })
297 }
299 // GetTokenHandler allows a client to exchange an authorization grant for an
300 // access token. See RFC 6749 Section 4.1.3.
301 func GetTokenHandler(w http.ResponseWriter, r *http.Request, context Context) {
302 // BUG(paddy): this function is an absolute mess. Honestly, it should be more general purpose, with each grant mode being called based on the grant_type POST form value. Basically, each grant type could have its own function, accepting the Request and ResponseWriter, and returning a boolean if the request should continue being processed or not. The function is in charge of validating the grant, which offers more flexible extensibiliy when adding grant types and easier testing, while also making the token distribution code easier to reuse in an elegant way. There is a minor problem that the token distribution code has some dependencies on the grant type being used (some grant types don't issue refresh tokens, for example) but that's a minor issue. Something like a map of string -> custom grantType struct would fix that. The struct could hold the function to call to validate the grant type and booleans that impact the token issuance. Then you do a map lookup based on the POST form value, and call the function or read the booleans as needed. If we use the same "register" pattern found in database/sql drivers, allowing grant types to register themselves, it'll be possible to add a grant type without even touching this function.
303 enc := json.NewEncoder(w)
304 grantType := r.PostFormValue("grant_type")
305 if grantType != "authorization_code" {
306 w.WriteHeader(http.StatusBadRequest)
307 renderJSONError(enc, "invalid_request")
308 return
309 }
310 code := r.PostFormValue("code")
311 if code == "" {
312 w.WriteHeader(http.StatusBadRequest)
313 renderJSONError(enc, "invalid_request")
314 return
315 }
316 redirectURI := r.PostFormValue("redirect_uri")
317 clientIDStr, clientSecret, fromAuthHeader := r.BasicAuth()
318 if !fromAuthHeader {
319 clientIDStr = r.PostFormValue("client_id")
320 }
321 clientID, err := uuid.Parse(clientIDStr)
322 if err != nil {
323 w.WriteHeader(http.StatusUnauthorized)
324 if fromAuthHeader {
325 w.Header().Set("WWW-Authenticate", "Basic")
326 }
327 renderJSONError(enc, "invalid_client")
328 return
329 }
330 client, err := context.GetClient(clientID)
331 if err != nil {
332 if err == ErrClientNotFound {
333 w.WriteHeader(http.StatusUnauthorized)
334 renderJSONError(enc, "invalid_client")
335 } else {
336 w.WriteHeader(http.StatusInternalServerError)
337 renderJSONError(enc, "server_error")
338 }
339 return
340 }
341 if client.Secret != clientSecret {
342 w.WriteHeader(http.StatusUnauthorized)
343 if fromAuthHeader {
344 w.Header().Set("WWW-Authenticate", "Basic")
345 }
346 renderJSONError(enc, "invalid_client")
347 return
348 }
349 grant, err := context.GetGrant(code)
350 if err != nil {
351 if err == ErrGrantNotFound {
352 w.WriteHeader(http.StatusBadRequest)
353 renderJSONError(enc, "invalid_grant")
354 return
355 }
356 w.WriteHeader(http.StatusInternalServerError)
357 renderJSONError(enc, "server_error")
358 return
359 }
360 if grant.RedirectURI != redirectURI {
361 w.WriteHeader(http.StatusBadRequest)
362 renderJSONError(enc, "invalid_grant")
363 return
364 }
365 if !grant.ClientID.Equal(clientID) {
366 w.WriteHeader(http.StatusBadRequest)
367 renderJSONError(enc, "invalid_grant")
368 return
369 }
370 token := Token{
371 AccessToken: uuid.NewID().String(),
372 RefreshToken: uuid.NewID().String(),
373 Created: time.Now(),
374 ExpiresIn: defaultTokenExpiration,
375 TokenType: "bearer",
376 Scope: grant.Scope,
377 ProfileID: grant.ProfileID,
378 }
379 err = context.SaveToken(token)
380 if err != nil {
381 w.WriteHeader(http.StatusInternalServerError)
382 renderJSONError(enc, "server_error")
383 return
384 }
385 resp := tokenResponse{
386 AccessToken: token.AccessToken,
387 RefreshToken: token.RefreshToken,
388 ExpiresIn: token.ExpiresIn,
389 TokenType: token.TokenType,
390 }
391 err = enc.Encode(resp)
392 if err != nil {
393 // TODO(paddy): log this or something
394 return
395 }
396 // BUG(paddy): we need to invalidate the grant for future requests
397 }
399 // TODO(paddy): exchange user credentials for access token
400 // TODO(paddy): exchange client credentials for access token
401 // TODO(paddy): implicit grant for access token
402 // TODO(paddy): exchange refresh token for access token