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