auth

Paddy 2015-01-18 Parent:9a5999963868 Child:da77e083cf02

116:e000b1c24fc0 Go to Latest

auth/oauth2.go

Make all tests that deal with the store interfaces go through the Context. This is mainly important so that pre- and post- save/retrieval/deletion/whatever transforms can be done without doing them in every single implementation of the store. Change the Endpoint URI property to be a string, not a *url.URL. This makes testing easier, JSON responses cleaner, and is all around just a better strategy. Just because we turn it into a URL every now and then doesn't mean that's how we need to store it. Add JSON tags to the Client type and Endpoint type. Create normalizeURI and normalizeURIString methods to... well, normalize the Endpoint URIs. This makes it so that we can compare them, and forgive some arbitrary user behaviour (like slashes, etc.) Add a NormalizedURI property to the Endpoint type. This is where we store the NormalizedURI, which is what we'll be using when we want to check if an endpoint is valid or not. For the sake of tests and predictability, however, we always want to redirect to the URI, not the NormalizedURI. Add checks to the Client creation API endpoint to give better errors. Now leaving out the Type won't be considered an invalid type, it will be considered a missing parameter. An empty name will be reported as a missing parameter, a name with too few characters will be reported as an insufficient name, and a name with too many characters will be reported as an overflow name. We gather as many of these errors as apply before returning. Check if an Endpoint URI is absolute before adding it as an endpoint, or return an invalid value error if it is not. Always return the errors array when creating a client. We could succeed in creating one or more things and still have errors. We should return anything that's created _as well as_ any errors encountered. Add unit testing for our CreateClientHandler. Fix our oauth2 tests so that if there's an error in the body, it's in the test logs. This should help debugging significantly. Fix our oauth2 tests so that the Profile only requires 1 iteration for its password hashing. This means each time we want to validate a session, it doesn't add a full second to our test runs. This is a big speed improvement for our tests. Add test helper methods for comparing API errors, API responses, and filling in server-generated information in a response that it's impossible to have an expectation around (e.g., IDs) so that we can use our comparison helpers to check if a response is as we expect it. Fix a typo in our Context helpers that was reporting no sessionStore being set _only_ when a sessionStore was set. So yes, the opposite of what we wanted. Oops. This was discovered by passing all our tests through the context. methods instead of operating on the stores themselves.

History
1 package auth
3 import (
4 "encoding/json"
5 "errors"
6 "html/template"
7 "log"
8 "net/http"
9 "net/url"
10 "sync"
11 "time"
13 "code.secondbit.org/uuid.hg"
14 "github.com/gorilla/mux"
15 )
17 const (
18 authCookieName = "auth"
19 defaultAuthorizationCodeExpiration = 600 // default to ten minute grant expirations
20 getAuthorizationCodeTemplateName = "get_grant"
21 )
23 var (
24 // ErrNoAuth is returned when an Authorization header is not present or is empty.
25 ErrNoAuth = errors.New("no authorization header supplied")
26 // ErrInvalidAuthFormat is returned when an Authorization header is present but not the correct format.
27 ErrInvalidAuthFormat = errors.New("authorization header is not in a valid format")
28 // ErrIncorrectAuth is returned when a user authentication attempt does not match the stored values.
29 ErrIncorrectAuth = errors.New("invalid authentication")
30 // ErrInvalidPassphraseScheme is returned when an undefined passphrase scheme is used.
31 ErrInvalidPassphraseScheme = errors.New("invalid passphrase scheme")
32 // ErrNoSession is returned when no session ID is passed with a request.
33 ErrNoSession = errors.New("no session ID found")
35 grantTypesMap = grantTypes{types: map[string]GrantType{}}
36 )
38 type grantTypes struct {
39 types map[string]GrantType
40 sync.RWMutex
41 }
43 // GrantType defines a set of functions and metadata around a specific authorization grant strategy.
44 //
45 // The Validate function will be called when requests are made that match the GrantType, and should write any
46 // errors to the ResponseWriter. It is responsible for determining if the grant is valid and a token should be issued.
47 // It must return the scope the grant was for and the ID of the Profile that issued the grant, as well as if the grant
48 // is valid or not. It must not be nil.
49 //
50 // The Invalidate function will be called when the grant has successfully generated a token and the token has successfully
51 // been conveyed to the user. The Invalidate function is always called asynchronously, outside the request. It should take
52 // care of marking the grant as used, if the GrantType requires grants to be one-time only grants. The Invalidate function
53 // can be nil.
54 //
55 // IssuesRefresh determines whether the GrantType should yield a refresh token as well as an access token. If true, the client
56 // will be issued a refresh token.
57 //
58 // The ReturnToken will be called when a token is created and needs to be returned to the client. If it returns true, the token
59 // was successfully returned and the Invalidate function will be called asynchronously.
60 type GrantType struct {
61 Validate func(w http.ResponseWriter, r *http.Request, context Context) (scope string, profileID uuid.ID, valid bool)
62 Invalidate func(r *http.Request, context Context) error
63 ReturnToken func(w http.ResponseWriter, r *http.Request, token Token, context Context) bool
64 IssuesRefresh bool
65 }
67 type tokenResponse struct {
68 AccessToken string `json:"access_token"`
69 TokenType string `json:"token_type,omitempty"`
70 ExpiresIn int32 `json:"expires_in,omitempty"`
71 RefreshToken string `json:"refresh_token,omitempty"`
72 }
74 type errorResponse struct {
75 Error string `json:"error"`
76 Description string `json:"error_description,omitempty"`
77 URI string `json:"error_uri,omitempty"`
78 }
80 // RegisterGrantType associates a string with a GrantType. When the string is used as the value for "grant_type" when obtaining
81 // an access token, the associated GrantType's properties will be used.
82 //
83 // RegisterGrantType should be called in the `init()` function of packages, much like database/sql registers drivers. It will panic
84 // if a GrantType tries to register under a string that already has a GrantType registered for it.
85 func RegisterGrantType(name string, g GrantType) {
86 grantTypesMap.Lock()
87 defer grantTypesMap.Unlock()
88 if _, ok := grantTypesMap.types[name]; ok {
89 panic("Duplicate registration of grant_type " + name)
90 }
91 grantTypesMap.types[name] = g
92 }
94 func findGrantType(name string) (GrantType, bool) {
95 grantTypesMap.RLock()
96 defer grantTypesMap.RUnlock()
97 t, ok := grantTypesMap.types[name]
98 return t, ok
99 }
101 func renderJSONError(enc *json.Encoder, errorType string) {
102 err := enc.Encode(errorResponse{
103 Error: errorType,
104 })
105 if err != nil {
106 log.Println(err)
107 }
108 }
110 // RenderJSONToken is an implementation of the ReturnToken function for GrantTypes. It returns the token using JSON
111 // according to the spec. See RFC 6479, Section 4.1.4.
112 func RenderJSONToken(w http.ResponseWriter, r *http.Request, token Token, context Context) bool {
113 enc := json.NewEncoder(w)
114 resp := tokenResponse{
115 AccessToken: token.AccessToken,
116 RefreshToken: token.RefreshToken,
117 ExpiresIn: token.ExpiresIn,
118 TokenType: token.TokenType,
119 }
120 w.Header().Set("Content-Type", "application/json")
121 err := enc.Encode(resp)
122 if err != nil {
123 log.Println(err)
124 return false
125 }
126 return true
127 }
129 // RegisterOAuth2 adds handlers to the passed router to handle the OAuth2 endpoints.
130 func RegisterOAuth2(r *mux.Router, context Context) {
131 r.Handle("/authorize", wrap(context, GetAuthorizationCodeHandler))
132 r.Handle("/token", wrap(context, GetTokenHandler))
133 }
135 // GetAuthorizationCodeHandler presents and processes the page for asking a user to grant access
136 // to their data. See RFC 6749, Section 4.1.
137 func GetAuthorizationCodeHandler(w http.ResponseWriter, r *http.Request, context Context) {
138 session, err := checkCookie(r, context)
139 if err != nil {
140 if err == ErrNoSession || err == ErrInvalidSession {
141 redir := buildLoginRedirect(r, context)
142 if redir == "" {
143 log.Println("No login URL configured.")
144 w.WriteHeader(http.StatusInternalServerError)
145 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
146 "internal_error": template.HTML("Missing login URL."),
147 })
148 return
149 }
150 http.Redirect(w, r, redir, http.StatusFound)
151 return
152 }
153 log.Println(err.Error())
154 w.WriteHeader(http.StatusInternalServerError)
155 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
156 "internal_error": template.HTML(err.Error()),
157 })
158 return
159 }
160 if r.URL.Query().Get("client_id") == "" {
161 w.WriteHeader(http.StatusBadRequest)
162 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
163 "error": template.HTML("Client ID must be specified in the request."),
164 })
165 return
166 }
167 clientID, err := uuid.Parse(r.URL.Query().Get("client_id"))
168 if err != nil {
169 w.WriteHeader(http.StatusBadRequest)
170 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
171 "error": template.HTML("client_id is not a valid Client ID."),
172 })
173 return
174 }
175 redirectURI := r.URL.Query().Get("redirect_uri")
176 client, err := context.GetClient(clientID)
177 if err != nil {
178 if err == ErrClientNotFound {
179 w.WriteHeader(http.StatusBadRequest)
180 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
181 "error": template.HTML("The specified Client couldn’t be found."),
182 })
183 } else {
184 log.Println(err.Error())
185 w.WriteHeader(http.StatusInternalServerError)
186 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
187 "internal_error": template.HTML(err.Error()),
188 })
189 }
190 return
191 }
192 // TODO(paddy): checking if the redirect URI is valid should be a helper function
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, getAuthorizationCodeTemplateName, map[string]interface{}{
200 "internal_error": template.HTML(err.Error()),
201 })
202 return
203 }
204 var validURI bool
205 if redirectURI != "" {
206 validURI, err = context.CheckEndpoint(clientID, redirectURI)
207 if err != nil {
208 if err == ErrEndpointURINotURL {
209 w.WriteHeader(http.StatusBadRequest)
210 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
211 "error": template.HTML("The redirect_uri specified is not valid."),
212 })
213 return
214 }
215 log.Println(err.Error())
216 w.WriteHeader(http.StatusInternalServerError)
217 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
218 "internal_error": template.HTML(err.Error()),
219 })
220 return
221 }
222 } else if redirectURI == "" && numEndpoints == 1 {
223 // if we don't specify the endpoint and there's only one endpoint, the
224 // request is valid, and we're redirecting to that one endpoint
225 validURI = true
226 endpoints, err := context.ListEndpoints(clientID, 1, 0)
227 if err != nil {
228 log.Println(err.Error())
229 w.WriteHeader(http.StatusInternalServerError)
230 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
231 "internal_error": template.HTML(err.Error()),
232 })
233 return
234 }
235 if len(endpoints) != 1 {
236 validURI = false
237 } else {
238 redirectURI = endpoints[0].URI
239 }
240 } else {
241 validURI = false
242 }
243 if !validURI {
244 w.WriteHeader(http.StatusBadRequest)
245 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
246 "error": template.HTML("The redirect_uri specified is not valid."),
247 })
248 return
249 }
250 redirectURL, err := url.Parse(redirectURI)
251 if err != nil {
252 w.WriteHeader(http.StatusBadRequest)
253 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
254 "error": template.HTML("The redirect_uri specified is not valid."),
255 })
256 return
257 }
258 scope := r.URL.Query().Get("scope")
259 state := r.URL.Query().Get("state")
260 if r.URL.Query().Get("response_type") != "code" {
261 q := redirectURL.Query()
262 q.Add("error", "invalid_request")
263 q.Add("state", state)
264 redirectURL.RawQuery = q.Encode()
265 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
266 return
267 }
268 if r.Method == "POST" {
269 // BUG(paddy): We need to implement CSRF protection when obtaining a grant code.
270 if r.PostFormValue("grant") == "approved" {
271 code := uuid.NewID().String()
272 authCode := AuthorizationCode{
273 Code: code,
274 Created: time.Now(),
275 ExpiresIn: defaultAuthorizationCodeExpiration,
276 ClientID: clientID,
277 Scope: scope,
278 RedirectURI: r.URL.Query().Get("redirect_uri"),
279 State: state,
280 ProfileID: session.ProfileID,
281 }
282 err := context.SaveAuthorizationCode(authCode)
283 if err != nil {
284 q := redirectURL.Query()
285 q.Add("error", "server_error")
286 q.Add("state", state)
287 redirectURL.RawQuery = q.Encode()
288 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
289 return
290 }
291 q := redirectURL.Query()
292 q.Add("code", code)
293 q.Add("state", state)
294 redirectURL.RawQuery = q.Encode()
295 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
296 return
297 }
298 q := redirectURL.Query()
299 q.Add("error", "access_denied")
300 q.Add("state", state)
301 redirectURL.RawQuery = q.Encode()
302 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
303 return
304 }
305 profile, err := context.GetProfileByID(session.ProfileID)
306 if err != nil {
307 q := redirectURL.Query()
308 q.Add("error", "server_error")
309 q.Add("state", state)
310 redirectURL.RawQuery = q.Encode()
311 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
312 return
313 }
314 w.WriteHeader(http.StatusOK)
315 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
316 "client": client,
317 "redirectURL": redirectURL,
318 "scope": scope,
319 "profile": profile,
320 })
321 }
323 // GetTokenHandler allows a client to exchange an authorization grant for an
324 // access token. See RFC 6749 Section 4.1.3.
325 func GetTokenHandler(w http.ResponseWriter, r *http.Request, context Context) {
326 enc := json.NewEncoder(w)
327 grantType := r.PostFormValue("grant_type")
328 gt, ok := findGrantType(grantType)
329 if !ok {
330 w.WriteHeader(http.StatusBadRequest)
331 renderJSONError(enc, "invalid_request")
332 return
333 }
334 scope, profileID, valid := gt.Validate(w, r, context)
335 if !valid {
336 return
337 }
338 refresh := ""
339 if gt.IssuesRefresh {
340 refresh = uuid.NewID().String()
341 }
342 token := Token{
343 AccessToken: uuid.NewID().String(),
344 RefreshToken: refresh,
345 Created: time.Now(),
346 ExpiresIn: defaultTokenExpiration,
347 RefreshExpiresIn: defaultRefreshTokenExpiration,
348 TokenType: "bearer",
349 Scope: scope,
350 ProfileID: profileID,
351 }
352 err := context.SaveToken(token)
353 if err != nil {
354 w.WriteHeader(http.StatusInternalServerError)
355 renderJSONError(enc, "server_error")
356 return
357 }
358 if gt.ReturnToken(w, r, token, context) && gt.Invalidate != nil {
359 go gt.Invalidate(r, context)
360 }
361 }
363 // TODO(paddy): exchange user credentials for access token
364 // TODO(paddy): exchange client credentials for access token
365 // TODO(paddy): implicit grant for access token
366 // TODO(paddy): exchange refresh token for access token