auth

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

161:849f3820b164 Go to Latest

auth/oauth2.go

Stop soft-deleting Profiles and actually delete them. The information we're storing in Profiles isn't unique enough that we should go through the hassle we're going through to soft-delete it. Add a deleteProfile method to our profileStore, and implement it for our postgres and memstore implementations. Add a DeleteProfile wrapper for our Context. Remove the Deleted property from the Profile type and the ProfileChange type, and update references to it. Stop cleaning up after our Profile in the UpdateProfileHandler, because there's no longer any way to delete the Profile from the UpdateProfileHandler. Update our get/list* methods so they don't filter on the non-existent Deleted property anymore. Update our SQL schema definition to not include the deleted column. Update our profile tests to use the DeleteProfile method and stop comparing the no-longer-existing Deleted property.

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