auth

Paddy 2015-12-14 Parent:581c60f8dd23

181:b7e685839a1b Go to Latest

auth/oauth2.go

Break out scopes and events. This repo has gotten unwieldy, and there are portions of it that need to be imported by a large number of other packages. For example, scopes will be used in almost every API we write. Rather than importing the entirety of this codebase into every API we write, I've opted to move the scope logic out into a scopes package, with a subpackage for the defined types, which is all most projects actually want to import. We also define some event type constants, and importing those shouldn't require a project to import all our dependencies, either. So I made an events subpackage that just holds those constants. This package has become a little bit of a red-headed stepchild and is do for a refactor, but I'm trying to put that off as long as I can. The refactoring of our scopes stuff has left a bug wherein a token can be granted for scopes that don't exist. I'm going to need to revisit that, and also how to limit scopes to only be granted to the users that should be able to request them. But that's a battle for another day.

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