auth
auth/oauth2.go
Fix go vet, fix imports, render JSON errors, deprecate getBasicAuth. Fix logging functions in our test file that were causing go vet to report errors (and which would have obscured the test output). Move some imports around to make the imports more consistent and our pre-commit hook happy. Create a helper to render JSON errors, and actually render those errors when obtaining a token using a grant. Deprecate our custom getBasicAuth in favour of the new BasicAuth() method on net/http.*Request objects, which was introduced in Go 1.4 (meaning Go 1.4 is now a requirement for compiling this.)
| paddy@51 | 1 package auth |
| paddy@51 | 2 |
| paddy@51 | 3 import ( |
| paddy@82 | 4 "crypto/sha256" |
| paddy@79 | 5 "encoding/hex" |
| paddy@69 | 6 "encoding/json" |
| paddy@69 | 7 "errors" |
| paddy@61 | 8 "html/template" |
| paddy@77 | 9 "log" |
| paddy@51 | 10 "net/http" |
| paddy@60 | 11 "net/url" |
| paddy@60 | 12 "time" |
| paddy@56 | 13 |
| paddy@69 | 14 "code.secondbit.org/pass" |
| paddy@56 | 15 "code.secondbit.org/uuid" |
| paddy@82 | 16 |
| paddy@82 | 17 "github.com/gorilla/mux" |
| paddy@51 | 18 ) |
| paddy@51 | 19 |
| paddy@60 | 20 const ( |
| paddy@69 | 21 authCookieName = "auth" |
| paddy@69 | 22 defaultGrantExpiration = 600 // default to ten minute grant expirations |
| paddy@60 | 23 getGrantTemplateName = "get_grant" |
| paddy@60 | 24 ) |
| paddy@51 | 25 |
| paddy@69 | 26 var ( |
| paddy@69 | 27 // ErrNoAuth is returned when an Authorization header is not present or is empty. |
| paddy@69 | 28 ErrNoAuth = errors.New("no authorization header supplied") |
| paddy@69 | 29 // ErrInvalidAuthFormat is returned when an Authorization header is present but not the correct format. |
| paddy@69 | 30 ErrInvalidAuthFormat = errors.New("authorization header is not in a valid format") |
| paddy@69 | 31 // ErrIncorrectAuth is returned when a user authentication attempt does not match the stored values. |
| paddy@69 | 32 ErrIncorrectAuth = errors.New("invalid authentication") |
| paddy@69 | 33 // ErrInvalidPassphraseScheme is returned when an undefined passphrase scheme is used. |
| paddy@69 | 34 ErrInvalidPassphraseScheme = errors.New("invalid passphrase scheme") |
| paddy@69 | 35 // ErrNoSession is returned when no session ID is passed with a request. |
| paddy@69 | 36 ErrNoSession = errors.New("no session ID found") |
| paddy@69 | 37 ) |
| paddy@69 | 38 |
| paddy@69 | 39 type tokenResponse struct { |
| paddy@69 | 40 AccessToken string `json:"access_token"` |
| paddy@69 | 41 TokenType string `json:"token_type,omitempty"` |
| paddy@69 | 42 ExpiresIn int32 `json:"expires_in,omitempty"` |
| paddy@69 | 43 RefreshToken string `json:"refresh_token,omitempty"` |
| paddy@69 | 44 } |
| paddy@69 | 45 |
| paddy@82 | 46 type errorResponse struct { |
| paddy@82 | 47 Error string `json:"error"` |
| paddy@82 | 48 Description string `json:"error_description,omitempty"` |
| paddy@82 | 49 URI string `json:"error_uri,omitempty"` |
| paddy@82 | 50 } |
| paddy@82 | 51 |
| paddy@82 | 52 func renderJSONError(enc *json.Encoder, errorType string) { |
| paddy@82 | 53 err := enc.Encode(errorResponse{ |
| paddy@82 | 54 Error: errorType, |
| paddy@82 | 55 }) |
| paddy@82 | 56 if err != nil { |
| paddy@82 | 57 // TODO(paddy): log this or something |
| paddy@69 | 58 } |
| paddy@69 | 59 } |
| paddy@69 | 60 |
| paddy@69 | 61 func checkCookie(r *http.Request, context Context) (Session, error) { |
| paddy@69 | 62 cookie, err := r.Cookie(authCookieName) |
| paddy@77 | 63 if err == http.ErrNoCookie { |
| paddy@77 | 64 return Session{}, ErrNoSession |
| paddy@77 | 65 } else if err != nil { |
| paddy@77 | 66 log.Println(err) |
| paddy@69 | 67 return Session{}, err |
| paddy@69 | 68 } |
| paddy@69 | 69 sess, err := context.GetSession(cookie.Value) |
| paddy@69 | 70 if err == ErrSessionNotFound { |
| paddy@69 | 71 return Session{}, ErrInvalidSession |
| paddy@69 | 72 } else if err != nil { |
| paddy@69 | 73 return Session{}, err |
| paddy@69 | 74 } |
| paddy@69 | 75 if !sess.Active { |
| paddy@69 | 76 return Session{}, ErrInvalidSession |
| paddy@69 | 77 } |
| paddy@69 | 78 return sess, nil |
| paddy@69 | 79 } |
| paddy@69 | 80 |
| paddy@77 | 81 func buildLoginRedirect(r *http.Request, context Context) string { |
| paddy@77 | 82 if context.loginURI == nil { |
| paddy@77 | 83 return "" |
| paddy@77 | 84 } |
| paddy@77 | 85 uri := *context.loginURI |
| paddy@77 | 86 q := uri.Query() |
| paddy@78 | 87 q.Set("from", r.URL.String()) |
| paddy@77 | 88 uri.RawQuery = q.Encode() |
| paddy@77 | 89 return uri.String() |
| paddy@77 | 90 } |
| paddy@77 | 91 |
| paddy@69 | 92 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@69 | 93 profile, err := context.GetProfileByLogin(user) |
| paddy@69 | 94 if err != nil { |
| paddy@79 | 95 if err == ErrProfileNotFound || err == ErrLoginNotFound { |
| paddy@69 | 96 return Profile{}, ErrIncorrectAuth |
| paddy@69 | 97 } |
| paddy@69 | 98 return Profile{}, err |
| paddy@69 | 99 } |
| paddy@69 | 100 switch profile.PassphraseScheme { |
| paddy@69 | 101 case 1: |
| paddy@79 | 102 realPass, err := hex.DecodeString(profile.Passphrase) |
| paddy@79 | 103 if err != nil { |
| paddy@79 | 104 return Profile{}, err |
| paddy@79 | 105 } |
| paddy@69 | 106 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(profile.Salt)) |
| paddy@79 | 107 if !pass.Compare(candidate, realPass) { |
| paddy@69 | 108 return Profile{}, ErrIncorrectAuth |
| paddy@69 | 109 } |
| paddy@69 | 110 default: |
| paddy@69 | 111 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@69 | 112 } |
| paddy@69 | 113 return profile, nil |
| paddy@69 | 114 } |
| paddy@69 | 115 |
| paddy@77 | 116 func wrap(context Context, f func(w http.ResponseWriter, r *http.Request, context Context)) http.Handler { |
| paddy@77 | 117 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| paddy@77 | 118 f(w, r, context) |
| paddy@77 | 119 }) |
| paddy@77 | 120 } |
| paddy@77 | 121 |
| paddy@77 | 122 // RegisterOAuth2 adds handlers to the passed router to handle the OAuth2 endpoints. |
| paddy@77 | 123 func RegisterOAuth2(r *mux.Router, context Context) { |
| paddy@77 | 124 r.Handle("/authorize", wrap(context, GetGrantHandler)) |
| paddy@77 | 125 r.Handle("/token", wrap(context, GetTokenHandler)) |
| paddy@77 | 126 } |
| paddy@77 | 127 |
| paddy@57 | 128 // GetGrantHandler presents and processes the page for asking a user to grant access |
| paddy@57 | 129 // to their data. See RFC 6749, Section 4.1. |
| paddy@51 | 130 func GetGrantHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@69 | 131 session, err := checkCookie(r, context) |
| paddy@69 | 132 if err != nil { |
| paddy@76 | 133 if err == ErrNoSession || err == ErrInvalidSession { |
| paddy@77 | 134 redir := buildLoginRedirect(r, context) |
| paddy@77 | 135 if redir == "" { |
| paddy@77 | 136 log.Println("No login URL configured.") |
| paddy@77 | 137 w.WriteHeader(http.StatusInternalServerError) |
| paddy@77 | 138 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@77 | 139 "internal_error": template.HTML("Missing login URL."), |
| paddy@77 | 140 }) |
| paddy@77 | 141 return |
| paddy@77 | 142 } |
| paddy@77 | 143 http.Redirect(w, r, redir, http.StatusFound) |
| paddy@77 | 144 return |
| paddy@69 | 145 } |
| paddy@77 | 146 log.Println(err.Error()) |
| paddy@77 | 147 w.WriteHeader(http.StatusInternalServerError) |
| paddy@77 | 148 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@77 | 149 "internal_error": template.HTML(err.Error()), |
| paddy@77 | 150 }) |
| paddy@77 | 151 return |
| paddy@69 | 152 } |
| paddy@56 | 153 if r.URL.Query().Get("client_id") == "" { |
| paddy@56 | 154 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 155 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 156 "error": template.HTML("Client ID must be specified in the request."), |
| paddy@56 | 157 }) |
| paddy@56 | 158 return |
| paddy@56 | 159 } |
| paddy@56 | 160 clientID, err := uuid.Parse(r.URL.Query().Get("client_id")) |
| paddy@56 | 161 if err != nil { |
| paddy@56 | 162 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 163 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 164 "error": template.HTML("client_id is not a valid Client ID."), |
| paddy@56 | 165 }) |
| paddy@56 | 166 return |
| paddy@56 | 167 } |
| paddy@64 | 168 redirectURI := r.URL.Query().Get("redirect_uri") |
| paddy@64 | 169 redirectURL, err := url.Parse(redirectURI) |
| paddy@64 | 170 if err != nil { |
| paddy@64 | 171 w.WriteHeader(http.StatusBadRequest) |
| paddy@64 | 172 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@64 | 173 "error": template.HTML("The redirect_uri specified is not valid."), |
| paddy@64 | 174 }) |
| paddy@64 | 175 return |
| paddy@64 | 176 } |
| paddy@56 | 177 client, err := context.GetClient(clientID) |
| paddy@56 | 178 if err != nil { |
| paddy@59 | 179 if err == ErrClientNotFound { |
| paddy@59 | 180 w.WriteHeader(http.StatusBadRequest) |
| paddy@59 | 181 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 182 "error": template.HTML("The specified Client couldn’t be found."), |
| paddy@59 | 183 }) |
| paddy@59 | 184 } else { |
| paddy@77 | 185 log.Println(err.Error()) |
| paddy@59 | 186 w.WriteHeader(http.StatusInternalServerError) |
| paddy@59 | 187 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 188 "internal_error": template.HTML(err.Error()), |
| paddy@59 | 189 }) |
| paddy@59 | 190 } |
| paddy@56 | 191 return |
| paddy@56 | 192 } |
| paddy@56 | 193 // whether a redirect URI is valid or not depends on the number of endpoints |
| paddy@56 | 194 // the client has registered |
| paddy@56 | 195 numEndpoints, err := context.CountEndpoints(clientID) |
| paddy@56 | 196 if err != nil { |
| paddy@77 | 197 log.Println(err.Error()) |
| paddy@56 | 198 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 199 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 200 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 201 }) |
| paddy@56 | 202 return |
| paddy@56 | 203 } |
| paddy@56 | 204 var validURI bool |
| paddy@58 | 205 if redirectURI != "" { |
| paddy@58 | 206 // BUG(paddy): We really should normalize URIs before trying to compare them. |
| paddy@58 | 207 validURI, err = context.CheckEndpoint(clientID, redirectURI) |
| paddy@56 | 208 if err != nil { |
| paddy@77 | 209 log.Println(err.Error()) |
| paddy@56 | 210 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 211 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 212 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 213 }) |
| paddy@56 | 214 return |
| paddy@56 | 215 } |
| paddy@56 | 216 } else if redirectURI == "" && numEndpoints == 1 { |
| paddy@56 | 217 // if we don't specify the endpoint and there's only one endpoint, the |
| paddy@56 | 218 // request is valid, and we're redirecting to that one endpoint |
| paddy@56 | 219 validURI = true |
| paddy@56 | 220 endpoints, err := context.ListEndpoints(clientID, 1, 0) |
| paddy@56 | 221 if err != nil { |
| paddy@77 | 222 log.Println(err.Error()) |
| paddy@56 | 223 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 224 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 225 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 226 }) |
| paddy@56 | 227 return |
| paddy@56 | 228 } |
| paddy@56 | 229 if len(endpoints) != 1 { |
| paddy@56 | 230 validURI = false |
| paddy@56 | 231 } else { |
| paddy@66 | 232 u := endpoints[0].URI // Copy it here to avoid grabbing a pointer to the memstore |
| paddy@66 | 233 redirectURI = u.String() |
| paddy@66 | 234 redirectURL = &u |
| paddy@56 | 235 } |
| paddy@56 | 236 } else { |
| paddy@56 | 237 validURI = false |
| paddy@56 | 238 } |
| paddy@56 | 239 if !validURI { |
| paddy@56 | 240 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 241 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 242 "error": template.HTML("The redirect_uri specified is not valid."), |
| paddy@56 | 243 }) |
| paddy@56 | 244 return |
| paddy@56 | 245 } |
| paddy@60 | 246 scope := r.URL.Query().Get("scope") |
| paddy@60 | 247 state := r.URL.Query().Get("state") |
| paddy@56 | 248 if r.URL.Query().Get("response_type") != "code" { |
| paddy@65 | 249 q := redirectURL.Query() |
| paddy@65 | 250 q.Add("error", "invalid_request") |
| paddy@65 | 251 q.Add("state", state) |
| paddy@65 | 252 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 253 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 254 return |
| paddy@56 | 255 } |
| paddy@56 | 256 if r.Method == "POST" { |
| paddy@63 | 257 // BUG(paddy): We need to implement CSRF protection when obtaining a grant code. |
| paddy@56 | 258 if r.PostFormValue("grant") == "approved" { |
| paddy@60 | 259 code := uuid.NewID().String() |
| paddy@60 | 260 grant := Grant{ |
| paddy@60 | 261 Code: code, |
| paddy@60 | 262 Created: time.Now(), |
| paddy@60 | 263 ExpiresIn: defaultGrantExpiration, |
| paddy@60 | 264 ClientID: clientID, |
| paddy@60 | 265 Scope: scope, |
| paddy@69 | 266 RedirectURI: r.URL.Query().Get("redirect_uri"), |
| paddy@60 | 267 State: state, |
| paddy@69 | 268 ProfileID: session.ProfileID, |
| paddy@60 | 269 } |
| paddy@60 | 270 err := context.SaveGrant(grant) |
| paddy@60 | 271 if err != nil { |
| paddy@66 | 272 q := redirectURL.Query() |
| paddy@66 | 273 q.Add("error", "server_error") |
| paddy@66 | 274 q.Add("state", state) |
| paddy@66 | 275 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 276 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 277 return |
| paddy@60 | 278 } |
| paddy@66 | 279 q := redirectURL.Query() |
| paddy@66 | 280 q.Add("code", code) |
| paddy@66 | 281 q.Add("state", state) |
| paddy@66 | 282 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 283 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 284 return |
| paddy@56 | 285 } |
| paddy@66 | 286 q := redirectURL.Query() |
| paddy@66 | 287 q.Add("error", "access_denied") |
| 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@56 | 292 } |
| paddy@51 | 293 w.WriteHeader(http.StatusOK) |
| paddy@56 | 294 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@56 | 295 "client": client, |
| paddy@56 | 296 }) |
| paddy@51 | 297 } |
| paddy@68 | 298 |
| paddy@69 | 299 // GetTokenHandler allows a client to exchange an authorization grant for an |
| paddy@69 | 300 // access token. See RFC 6749 Section 4.1.3. |
| paddy@69 | 301 func GetTokenHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@69 | 302 enc := json.NewEncoder(w) |
| paddy@69 | 303 grantType := r.PostFormValue("grant_type") |
| paddy@69 | 304 if grantType != "authorization_code" { |
| paddy@82 | 305 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 306 renderJSONError(enc, "invalid_request") |
| paddy@69 | 307 return |
| paddy@69 | 308 } |
| paddy@69 | 309 code := r.PostFormValue("code") |
| paddy@69 | 310 if code == "" { |
| paddy@82 | 311 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 312 renderJSONError(enc, "invalid_request") |
| paddy@69 | 313 return |
| paddy@69 | 314 } |
| paddy@69 | 315 redirectURI := r.PostFormValue("redirect_uri") |
| paddy@82 | 316 clientIDStr, clientSecret, fromAuthHeader := r.BasicAuth() |
| paddy@82 | 317 if !fromAuthHeader { |
| paddy@69 | 318 clientIDStr = r.PostFormValue("client_id") |
| paddy@69 | 319 } |
| paddy@69 | 320 clientID, err := uuid.Parse(clientIDStr) |
| paddy@69 | 321 if err != nil { |
| paddy@82 | 322 w.WriteHeader(http.StatusUnauthorized) |
| paddy@82 | 323 if fromAuthHeader { |
| paddy@82 | 324 w.Header().Set("WWW-Authenticate", "Basic") |
| paddy@82 | 325 } |
| paddy@82 | 326 renderJSONError(enc, "invalid_client") |
| paddy@69 | 327 return |
| paddy@69 | 328 } |
| paddy@69 | 329 client, err := context.GetClient(clientID) |
| paddy@69 | 330 if err != nil { |
| paddy@69 | 331 if err == ErrClientNotFound { |
| paddy@82 | 332 w.WriteHeader(http.StatusUnauthorized) |
| paddy@82 | 333 renderJSONError(enc, "invalid_client") |
| paddy@69 | 334 } else { |
| paddy@82 | 335 w.WriteHeader(http.StatusInternalServerError) |
| paddy@82 | 336 renderJSONError(enc, "server_error") |
| paddy@69 | 337 } |
| paddy@69 | 338 return |
| paddy@69 | 339 } |
| paddy@69 | 340 if client.Secret != clientSecret { |
| paddy@82 | 341 w.WriteHeader(http.StatusUnauthorized) |
| paddy@82 | 342 if fromAuthHeader { |
| paddy@82 | 343 w.Header().Set("WWW-Authenticate", "Basic") |
| paddy@82 | 344 } |
| paddy@82 | 345 renderJSONError(enc, "invalid_client") |
| paddy@69 | 346 return |
| paddy@69 | 347 } |
| paddy@69 | 348 grant, err := context.GetGrant(code) |
| paddy@69 | 349 if err != nil { |
| paddy@69 | 350 if err == ErrGrantNotFound { |
| paddy@82 | 351 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 352 renderJSONError(enc, "invalid_grant") |
| paddy@69 | 353 return |
| paddy@69 | 354 } |
| paddy@82 | 355 w.WriteHeader(http.StatusInternalServerError) |
| paddy@82 | 356 renderJSONError(enc, "server_error") |
| paddy@81 | 357 return |
| paddy@69 | 358 } |
| paddy@69 | 359 if grant.RedirectURI != redirectURI { |
| paddy@82 | 360 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 361 renderJSONError(enc, "invalid_grant") |
| paddy@81 | 362 return |
| paddy@69 | 363 } |
| paddy@69 | 364 if !grant.ClientID.Equal(clientID) { |
| paddy@82 | 365 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 366 renderJSONError(enc, "invalid_grant") |
| paddy@81 | 367 return |
| paddy@69 | 368 } |
| paddy@69 | 369 token := Token{ |
| paddy@69 | 370 AccessToken: uuid.NewID().String(), |
| paddy@69 | 371 RefreshToken: uuid.NewID().String(), |
| paddy@69 | 372 Created: time.Now(), |
| paddy@69 | 373 ExpiresIn: defaultTokenExpiration, |
| paddy@81 | 374 TokenType: "bearer", |
| paddy@69 | 375 Scope: grant.Scope, |
| paddy@69 | 376 ProfileID: grant.ProfileID, |
| paddy@69 | 377 } |
| paddy@69 | 378 err = context.SaveToken(token) |
| paddy@69 | 379 if err != nil { |
| paddy@82 | 380 w.WriteHeader(http.StatusInternalServerError) |
| paddy@82 | 381 renderJSONError(enc, "server_error") |
| paddy@81 | 382 return |
| paddy@69 | 383 } |
| paddy@69 | 384 resp := tokenResponse{ |
| paddy@69 | 385 AccessToken: token.AccessToken, |
| paddy@69 | 386 RefreshToken: token.RefreshToken, |
| paddy@69 | 387 ExpiresIn: token.ExpiresIn, |
| paddy@69 | 388 TokenType: token.TokenType, |
| paddy@69 | 389 } |
| paddy@69 | 390 err = enc.Encode(resp) |
| paddy@69 | 391 if err != nil { |
| paddy@69 | 392 // TODO(paddy): log this or something |
| paddy@69 | 393 return |
| paddy@69 | 394 } |
| paddy@81 | 395 // BUG(paddy): we need to invalidate the grant for future requests |
| paddy@69 | 396 } |
| paddy@69 | 397 |
| paddy@68 | 398 // TODO(paddy): exchange user credentials for access token |
| paddy@68 | 399 // TODO(paddy): exchange client credentials for access token |
| paddy@68 | 400 // TODO(paddy): implicit grant for access token |
| paddy@68 | 401 // TODO(paddy): exchange refresh token for access token |