auth

Paddy 2015-04-11 Parent:3e8964a914ef Child:73e12d5a1124

160:48200d8c4036 Go to Latest

auth/oauth2.go

Start to support deleting profiles through the API. Create a removeLoginsByProfile method on the profileStore, to allow an easy way to bulk-delete logins associated with a Profile after the Profile has been deleted. Create postgres and memstore implementations of the removeLoginsByProfile method. Create a cleanUpAfterProfileDeletion helper method that will clean up the child objects of a Profile (its Sessions, Tokens, Clients, etc.). The intended usage is to call this in a goroutine after a Profile has been deleted, to try and get things back in order. Detect when the UpdateProfileHandler API is used to set the Deleted flag of a Profile to true, and clean up after the Profile when that's the case. Add a DeleteProfileHandler API endpoint that is a shortcut to setting the Deleted flag of a Profile to true and cleaning up after the Profile. The problem with our approach thus far is that some of it is reversible and some is not. If a Profile is maliciously/accidentally deleted, it's simple enough to use the API as a superuser to restore the Profile. But doing that will not (and cannot) restore the Logins associated with that Profile, for example. While it would be nice to add a Deleted flag to our Logins that we could simply toggle, that would wreak havoc with our database constraints and ensuring uniqueness of Login values. I still don't have a solution for this, outside the superuser manually restoring a Login for the Profile, after which the user can authenticate themselves and add more Logins as desired. But there has to be a better way. I suppose since the passphrase is being stored with the Profile and not the Login, we could offer an endpoint that would automate this, but... well, that would be tricky. It would require the user remembering their Profile ID, and let's be honest, nobody's going to remember a UUID. Maybe such an endpoint would help from a customer service standpoint: we identify their Profile manually, then send them to /profiles/ID/restorelogin or something, and that lets them add a Login back to the Profile. I'll figure it out later. For now, we know we at least have enough information to identify a user is who they say they are and resolve the situation manually.

History
1 package auth
3 import (
4 "crypto/rand"
5 "encoding/hex"
6 "encoding/json"
7 "errors"
8 "html/template"
9 "io"
10 "log"
11 "net/http"
12 "net/url"
13 "strconv"
14 "strings"
15 "sync"
16 "time"
18 "code.secondbit.org/uuid.hg"
19 "github.com/gorilla/mux"
20 )
22 const (
23 defaultAuthorizationCodeExpiration = 600 // default to ten minute grant expirations
24 getAuthorizationCodeTemplateName = "get_grant"
25 )
27 var (
28 // ErrNoAuth is returned when an Authorization header is not present or is empty.
29 ErrNoAuth = errors.New("no authorization header supplied")
30 // ErrInvalidAuthFormat is returned when an Authorization header is present but not the correct format.
31 ErrInvalidAuthFormat = errors.New("authorization header is not in a valid format")
32 // ErrIncorrectAuth is returned when a user authentication attempt does not match the stored values.
33 ErrIncorrectAuth = errors.New("invalid authentication")
34 // ErrInvalidPassphraseScheme is returned when an undefined passphrase scheme is used.
35 ErrInvalidPassphraseScheme = errors.New("invalid passphrase scheme")
36 // ErrNoSession is returned when no session ID is passed with a request.
37 ErrNoSession = errors.New("no session ID found")
39 grantTypesMap = grantTypes{types: map[string]GrantType{}}
40 )
42 type grantTypes struct {
43 types map[string]GrantType
44 sync.RWMutex
45 }
47 // GrantType defines a set of functions and metadata around a specific authorization grant strategy.
48 //
49 // The Validate function will be called when requests are made that match the GrantType, and should write any
50 // errors to the ResponseWriter. It is responsible for determining if the grant is valid and a token should be issued.
51 // 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
52 // is valid or not. It must not be nil.
53 //
54 // The Invalidate function will be called when the grant has successfully generated a token and the token has successfully
55 // been conveyed to the user. The Invalidate function is always called asynchronously, outside the request. It should take
56 // care of marking the grant as used, if the GrantType requires grants to be one-time only grants. The Invalidate function
57 // can be nil.
58 //
59 // IssuesRefresh determines whether the GrantType should yield a refresh token as well as an access token. If true, the client
60 // will be issued a refresh token.
61 //
62 // AllowsPublic determines whether the GrantType should allow public clients to use that grant. If true, clients without
63 // credentials will be able to use the grant to obtain a token.
64 //
65 // AuditString should return the string that will be saved in the resulting Token's CreatedFrom field, as an audit log of how
66 // the Token was authorized.
67 //
68 // The ReturnToken will be called when a token is created and needs to be returned to the client. If it returns true, the token
69 // was successfully returned and the Invalidate function will be called asynchronously.
70 type GrantType struct {
71 Validate func(w http.ResponseWriter, r *http.Request, context Context) (scopes []string, profileID uuid.ID, valid bool)
72 Invalidate func(r *http.Request, context Context) error
73 ReturnToken func(w http.ResponseWriter, r *http.Request, token Token, context Context) bool
74 AuditString func(r *http.Request) string
75 IssuesRefresh bool
76 AllowsPublic bool
77 }
79 type tokenResponse struct {
80 AccessToken string `json:"access_token"`
81 TokenType string `json:"token_type,omitempty"`
82 ExpiresIn int32 `json:"expires_in,omitempty"`
83 RefreshToken string `json:"refresh_token,omitempty"`
84 }
86 type errorResponse struct {
87 Error string `json:"error"`
88 Description string `json:"error_description,omitempty"`
89 URI string `json:"error_uri,omitempty"`
90 }
92 // RegisterGrantType associates a string with a GrantType. When the string is used as the value for "grant_type" when obtaining
93 // an access token, the associated GrantType's properties will be used.
94 //
95 // RegisterGrantType should be called in the `init()` function of packages, much like database/sql registers drivers. It will panic
96 // if a GrantType tries to register under a string that already has a GrantType registered for it.
97 func RegisterGrantType(name string, g GrantType) {
98 grantTypesMap.Lock()
99 defer grantTypesMap.Unlock()
100 if _, ok := grantTypesMap.types[name]; ok {
101 panic("Duplicate registration of grant_type " + name)
102 }
103 grantTypesMap.types[name] = g
104 }
106 func findGrantType(name string) (GrantType, bool) {
107 grantTypesMap.RLock()
108 defer grantTypesMap.RUnlock()
109 t, ok := grantTypesMap.types[name]
110 return t, ok
111 }
113 func renderJSONError(enc *json.Encoder, errorType string) {
114 err := enc.Encode(errorResponse{
115 Error: errorType,
116 })
117 if err != nil {
118 log.Println(err)
119 }
120 }
122 // RenderJSONToken is an implementation of the ReturnToken function for GrantTypes. It returns the token using JSON
123 // according to the spec. See RFC 6479, Section 4.1.4.
124 func RenderJSONToken(w http.ResponseWriter, r *http.Request, token Token, context Context) bool {
125 enc := json.NewEncoder(w)
126 resp := tokenResponse{
127 AccessToken: token.AccessToken,
128 RefreshToken: token.RefreshToken,
129 ExpiresIn: token.ExpiresIn,
130 TokenType: token.TokenType,
131 }
132 w.Header().Set("Content-Type", "application/json")
133 err := enc.Encode(resp)
134 if err != nil {
135 log.Println(err)
136 return false
137 }
138 return true
139 }
141 // RegisterOAuth2 adds handlers to the passed router to handle the OAuth2 endpoints.
142 func RegisterOAuth2(r *mux.Router, context Context) {
143 r.Handle("/authorize", wrap(context, GetAuthorizationCodeHandler))
144 r.Handle("/token", wrap(context, GetTokenHandler))
145 }
147 // GetAuthorizationCodeHandler presents and processes the page for asking a user to grant access
148 // to their data. See RFC 6749, Section 4.1.
149 func GetAuthorizationCodeHandler(w http.ResponseWriter, r *http.Request, context Context) {
150 session, err := checkCookie(r, context)
151 if err != nil {
152 if err == ErrNoSession || err == ErrInvalidSession {
153 redir := buildLoginRedirect(r, context)
154 if redir == "" {
155 log.Println("No login URL configured.")
156 w.WriteHeader(http.StatusInternalServerError)
157 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
158 "internal_error": template.HTML("Missing login URL."),
159 })
160 return
161 }
162 http.Redirect(w, r, redir, http.StatusFound)
163 return
164 }
165 log.Println(err.Error())
166 w.WriteHeader(http.StatusInternalServerError)
167 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
168 "internal_error": template.HTML(err.Error()),
169 })
170 return
171 }
172 if r.URL.Query().Get("client_id") == "" {
173 w.WriteHeader(http.StatusBadRequest)
174 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
175 "error": template.HTML("Client ID must be specified in the request."),
176 })
177 return
178 }
179 clientID, err := uuid.Parse(r.URL.Query().Get("client_id"))
180 if err != nil {
181 w.WriteHeader(http.StatusBadRequest)
182 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
183 "error": template.HTML("client_id is not a valid Client ID."),
184 })
185 return
186 }
187 redirectURI := r.URL.Query().Get("redirect_uri")
188 client, err := context.GetClient(clientID)
189 if err != nil {
190 if err == ErrClientNotFound {
191 w.WriteHeader(http.StatusBadRequest)
192 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
193 "error": template.HTML("The specified Client couldn’t be found."),
194 })
195 } else {
196 log.Println(err.Error())
197 w.WriteHeader(http.StatusInternalServerError)
198 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
199 "internal_error": template.HTML(err.Error()),
200 })
201 }
202 return
203 }
204 // BUG(paddy): Checking if the redirect URI is valid should be a helper function.
206 // whether a redirect URI is valid or not depends on the number of endpoints
207 // the client has registered
208 numEndpoints, err := context.CountEndpoints(clientID)
209 if err != nil {
210 log.Println(err.Error())
211 w.WriteHeader(http.StatusInternalServerError)
212 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
213 "internal_error": template.HTML(err.Error()),
214 })
215 return
216 }
217 var validURI bool
218 if redirectURI != "" {
219 validURI, err = context.CheckEndpoint(clientID, redirectURI)
220 if err != nil {
221 if err == ErrEndpointURINotURL {
222 w.WriteHeader(http.StatusBadRequest)
223 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
224 "error": template.HTML("The redirect_uri specified is not valid."),
225 })
226 return
227 }
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 } else if redirectURI == "" && numEndpoints == 1 {
236 // if we don't specify the endpoint and there's only one endpoint, the
237 // request is valid, and we're redirecting to that one endpoint
238 validURI = true
239 endpoints, err := context.ListEndpoints(clientID, 1, 0)
240 if err != nil {
241 log.Println(err.Error())
242 w.WriteHeader(http.StatusInternalServerError)
243 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
244 "internal_error": template.HTML(err.Error()),
245 })
246 return
247 }
248 if len(endpoints) != 1 {
249 validURI = false
250 } else {
251 redirectURI = endpoints[0].URI
252 }
253 } else {
254 validURI = false
255 }
256 if !validURI {
257 w.WriteHeader(http.StatusBadRequest)
258 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
259 "error": template.HTML("The redirect_uri specified is not valid."),
260 })
261 return
262 }
263 redirectURL, err := url.Parse(redirectURI)
264 if err != nil {
265 w.WriteHeader(http.StatusBadRequest)
266 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
267 "error": template.HTML("The redirect_uri specified is not valid."),
268 })
269 return
270 }
271 state := r.URL.Query().Get("state")
272 responseType := r.URL.Query().Get("response_type")
273 q := redirectURL.Query()
274 q.Add("state", state)
275 if responseType != "code" && responseType != "token" {
276 q.Add("error", "invalid_request")
277 redirectURL.RawQuery = q.Encode()
278 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
279 return
280 }
281 scopeParams := strings.Split(r.URL.Query().Get("scope"), " ")
282 scopes, err := context.GetScopes(scopeParams)
283 if err != nil {
284 if err == ErrScopeNotFound {
285 q.Add("error", "invalid_scope")
286 redirectURL.RawQuery = q.Encode()
287 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
288 return
289 }
290 log.Println("Error retrieving scopes:", err)
291 q.Add("error", "server_error")
292 redirectURL.RawQuery = q.Encode()
293 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
294 return
295 }
296 if r.Method == "POST" {
297 if checkCSRF(r, session) != nil {
298 log.Println("CSRF attempt detected.")
299 w.WriteHeader(http.StatusInternalServerError)
300 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
301 "error": template.HTML("There was an error authenticating your request."),
302 })
303 return
304 }
305 if r.PostFormValue("grant") == "approved" {
306 var fragment bool
307 switch responseType {
308 case "code":
309 code := make([]byte, 16)
310 _, err := io.ReadFull(rand.Reader, code)
311 if err != nil {
312 log.Printf("Error generating code: %#+v\n", err)
313 q.Add("error", "server_error")
314 break
315 }
316 authCode := AuthorizationCode{
317 Code: hex.EncodeToString(code),
318 Created: time.Now(),
319 ExpiresIn: defaultAuthorizationCodeExpiration,
320 ClientID: clientID,
321 Scopes: scopeParams,
322 RedirectURI: r.URL.Query().Get("redirect_uri"),
323 State: state,
324 ProfileID: session.ProfileID,
325 }
326 err = context.SaveAuthorizationCode(authCode)
327 if err != nil {
328 log.Println("Error saving authorization code:", err)
329 q.Add("error", "server_error")
330 break
331 }
332 q.Add("code", authCode.Code)
333 case "token":
334 token := Token{
335 AccessToken: uuid.NewID().String(),
336 Created: time.Now(),
337 CreatedFrom: "implicit",
338 ExpiresIn: defaultTokenExpiration,
339 TokenType: "bearer",
340 Scopes: scopeParams,
341 ProfileID: session.ProfileID,
342 ClientID: clientID,
343 }
344 err := context.SaveToken(token)
345 if err != nil {
346 log.Println("Error saving token:", err)
347 q.Add("error", "server_error")
348 break
349 }
350 q = url.Values{} // we're not altering the querystring, so don't clone it
351 q.Add("access_token", token.AccessToken)
352 q.Add("token_type", token.TokenType)
353 q.Add("expires_in", strconv.FormatInt(int64(token.ExpiresIn), 10))
354 q.Add("scope", strings.Join(token.Scopes, " "))
355 q.Add("state", state) // we wiped out the old values, so we need to set the state again
356 fragment = true
357 }
358 if fragment {
359 redirectURL.Fragment = q.Encode()
360 } else {
361 redirectURL.RawQuery = q.Encode()
362 }
363 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
364 return
365 }
366 q.Add("error", "access_denied")
367 redirectURL.RawQuery = q.Encode()
368 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
369 return
370 }
371 profile, err := context.GetProfileByID(session.ProfileID)
372 if err != nil {
373 log.Println("Error getting profile from session:", err)
374 q.Add("error", "server_error")
375 redirectURL.RawQuery = q.Encode()
376 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
377 return
378 }
379 w.WriteHeader(http.StatusOK)
380 context.Render(w, getAuthorizationCodeTemplateName, map[string]interface{}{
381 "client": client,
382 "redirectURL": redirectURL,
383 "scopes": scopes,
384 "profile": profile,
385 "csrftoken": session.CSRFToken,
386 })
387 }
389 // GetTokenHandler allows a client to exchange an authorization grant for an
390 // access token. See RFC 6749 Section 4.1.3.
391 func GetTokenHandler(w http.ResponseWriter, r *http.Request, context Context) {
392 enc := json.NewEncoder(w)
393 grantType := r.PostFormValue("grant_type")
394 gt, ok := findGrantType(grantType)
395 if !ok {
396 w.WriteHeader(http.StatusBadRequest)
397 renderJSONError(enc, "invalid_request")
398 return
399 }
400 clientID, success := verifyClient(w, r, gt.AllowsPublic, context)
401 if !success {
402 return
403 }
404 scopes, profileID, valid := gt.Validate(w, r, context)
405 if !valid {
406 return
407 }
408 refresh := ""
409 if gt.IssuesRefresh {
410 refresh = uuid.NewID().String()
411 }
412 token := Token{
413 AccessToken: uuid.NewID().String(),
414 RefreshToken: refresh,
415 Created: time.Now(),
416 CreatedFrom: gt.AuditString(r),
417 ExpiresIn: defaultTokenExpiration,
418 TokenType: "bearer",
419 Scopes: scopes,
420 ProfileID: profileID,
421 ClientID: clientID,
422 }
423 err := context.SaveToken(token)
424 if err != nil {
425 w.WriteHeader(http.StatusInternalServerError)
426 renderJSONError(enc, "server_error")
427 return
428 }
429 if gt.ReturnToken(w, r, token, context) && gt.Invalidate != nil {
430 go gt.Invalidate(r, context)
431 }
432 }