auth
auth/oauth2.go
Rename http.go. We're going to have a lot of HTTP handlers, and I'd rather make it clear that this is taking care of our OAuth2 HTTP logic. So rename the file, and we'll put the API handlers in their files, or something.
| paddy@51 | 1 package auth |
| paddy@51 | 2 |
| paddy@51 | 3 import ( |
| paddy@69 | 4 "encoding/base64" |
| paddy@69 | 5 "encoding/json" |
| paddy@69 | 6 "errors" |
| paddy@61 | 7 "html/template" |
| paddy@51 | 8 "net/http" |
| paddy@60 | 9 "net/url" |
| paddy@69 | 10 "strings" |
| paddy@60 | 11 "time" |
| paddy@56 | 12 |
| paddy@69 | 13 "crypto/sha256" |
| paddy@69 | 14 "code.secondbit.org/pass" |
| paddy@56 | 15 "code.secondbit.org/uuid" |
| paddy@51 | 16 ) |
| paddy@51 | 17 |
| paddy@60 | 18 const ( |
| paddy@69 | 19 authCookieName = "auth" |
| paddy@69 | 20 defaultGrantExpiration = 600 // default to ten minute grant expirations |
| paddy@60 | 21 getGrantTemplateName = "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@69 | 35 ) |
| paddy@69 | 36 |
| paddy@69 | 37 type tokenResponse struct { |
| paddy@69 | 38 AccessToken string `json:"access_token"` |
| paddy@69 | 39 TokenType string `json:"token_type,omitempty"` |
| paddy@69 | 40 ExpiresIn int32 `json:"expires_in,omitempty"` |
| paddy@69 | 41 RefreshToken string `json:"refresh_token,omitempty"` |
| paddy@69 | 42 } |
| paddy@69 | 43 |
| paddy@69 | 44 func getBasicAuth(r *http.Request) (un, pass string, err error) { |
| paddy@69 | 45 auth := r.Header.Get("Authorization") |
| paddy@69 | 46 if auth == "" { |
| paddy@69 | 47 return "", "", ErrNoAuth |
| paddy@69 | 48 } |
| paddy@69 | 49 pieces := strings.SplitN(auth, " ", 2) |
| paddy@69 | 50 if pieces[0] != "Basic" { |
| paddy@69 | 51 return "", "", ErrInvalidAuthFormat |
| paddy@69 | 52 } |
| paddy@69 | 53 decoded, err := base64.StdEncoding.DecodeString(pieces[1]) |
| paddy@69 | 54 if err != nil { |
| paddy@69 | 55 return "", "", ErrInvalidAuthFormat |
| paddy@69 | 56 } |
| paddy@69 | 57 info := strings.SplitN(string(decoded), ":", 2) |
| paddy@69 | 58 return info[0], info[1], nil |
| 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@69 | 63 if err != nil { |
| paddy@69 | 64 if err == http.ErrNoCookie { |
| paddy@69 | 65 return Session{}, ErrNoSession |
| paddy@69 | 66 } |
| paddy@69 | 67 return Session{}, err |
| paddy@69 | 68 } |
| paddy@69 | 69 if cookie.Name != authCookieName || !cookie.Expires.After(time.Now()) || |
| paddy@69 | 70 !cookie.Secure || !cookie.HttpOnly { |
| paddy@69 | 71 return Session{}, ErrInvalidSession |
| paddy@69 | 72 } |
| paddy@69 | 73 sess, err := context.GetSession(cookie.Value) |
| paddy@69 | 74 if err == ErrSessionNotFound { |
| paddy@69 | 75 return Session{}, ErrInvalidSession |
| paddy@69 | 76 } else if err != nil { |
| paddy@69 | 77 return Session{}, err |
| paddy@69 | 78 } |
| paddy@69 | 79 if !sess.Active { |
| paddy@69 | 80 return Session{}, ErrInvalidSession |
| paddy@69 | 81 } |
| paddy@69 | 82 return sess, nil |
| paddy@69 | 83 } |
| paddy@69 | 84 |
| paddy@69 | 85 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@69 | 86 profile, err := context.GetProfileByLogin(user) |
| paddy@69 | 87 if err != nil { |
| paddy@69 | 88 if err == ErrProfileNotFound { |
| paddy@69 | 89 return Profile{}, ErrIncorrectAuth |
| paddy@69 | 90 } |
| paddy@69 | 91 return Profile{}, err |
| paddy@69 | 92 } |
| paddy@69 | 93 switch profile.PassphraseScheme { |
| paddy@69 | 94 case 1: |
| paddy@69 | 95 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(profile.Salt)) |
| paddy@69 | 96 if !pass.Compare(candidate, []byte(profile.Passphrase)) { |
| paddy@69 | 97 return Profile{}, ErrIncorrectAuth |
| paddy@69 | 98 } |
| paddy@69 | 99 default: |
| paddy@69 | 100 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@69 | 101 } |
| paddy@69 | 102 return profile, nil |
| paddy@69 | 103 } |
| paddy@69 | 104 |
| paddy@57 | 105 // GetGrantHandler presents and processes the page for asking a user to grant access |
| paddy@57 | 106 // to their data. See RFC 6749, Section 4.1. |
| paddy@51 | 107 func GetGrantHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@69 | 108 session, err := checkCookie(r, context) |
| paddy@69 | 109 if err != nil { |
| paddy@69 | 110 if err == ErrNoSession { |
| paddy@69 | 111 // TODO(paddy): redirect to login screen |
| paddy@69 | 112 //return |
| paddy@69 | 113 } |
| paddy@69 | 114 if err == ErrInvalidSession { |
| paddy@69 | 115 // TODO(paddy): return an access denied error |
| paddy@69 | 116 //return |
| paddy@69 | 117 } |
| paddy@69 | 118 // TODO(paddy): return a server error |
| paddy@69 | 119 //return |
| paddy@69 | 120 } |
| paddy@56 | 121 if r.URL.Query().Get("client_id") == "" { |
| paddy@56 | 122 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 123 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 124 "error": template.HTML("Client ID must be specified in the request."), |
| paddy@56 | 125 }) |
| paddy@56 | 126 return |
| paddy@56 | 127 } |
| paddy@56 | 128 clientID, err := uuid.Parse(r.URL.Query().Get("client_id")) |
| paddy@56 | 129 if err != nil { |
| paddy@56 | 130 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 131 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 132 "error": template.HTML("client_id is not a valid Client ID."), |
| paddy@56 | 133 }) |
| paddy@56 | 134 return |
| paddy@56 | 135 } |
| paddy@64 | 136 redirectURI := r.URL.Query().Get("redirect_uri") |
| paddy@64 | 137 redirectURL, err := url.Parse(redirectURI) |
| paddy@64 | 138 if err != nil { |
| paddy@64 | 139 w.WriteHeader(http.StatusBadRequest) |
| paddy@64 | 140 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@64 | 141 "error": template.HTML("The redirect_uri specified is not valid."), |
| paddy@64 | 142 }) |
| paddy@64 | 143 return |
| paddy@64 | 144 } |
| paddy@56 | 145 client, err := context.GetClient(clientID) |
| paddy@56 | 146 if err != nil { |
| paddy@59 | 147 if err == ErrClientNotFound { |
| paddy@59 | 148 w.WriteHeader(http.StatusBadRequest) |
| paddy@59 | 149 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 150 "error": template.HTML("The specified Client couldn’t be found."), |
| paddy@59 | 151 }) |
| paddy@59 | 152 } else { |
| paddy@59 | 153 w.WriteHeader(http.StatusInternalServerError) |
| paddy@59 | 154 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 155 "internal_error": template.HTML(err.Error()), |
| paddy@59 | 156 }) |
| paddy@59 | 157 } |
| paddy@56 | 158 return |
| paddy@56 | 159 } |
| paddy@56 | 160 // whether a redirect URI is valid or not depends on the number of endpoints |
| paddy@56 | 161 // the client has registered |
| paddy@56 | 162 numEndpoints, err := context.CountEndpoints(clientID) |
| paddy@56 | 163 if err != nil { |
| paddy@56 | 164 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 165 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 166 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 167 }) |
| paddy@56 | 168 return |
| paddy@56 | 169 } |
| paddy@56 | 170 var validURI bool |
| paddy@58 | 171 if redirectURI != "" { |
| paddy@58 | 172 // BUG(paddy): We really should normalize URIs before trying to compare them. |
| paddy@58 | 173 validURI, err = context.CheckEndpoint(clientID, redirectURI) |
| paddy@56 | 174 if err != nil { |
| paddy@56 | 175 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 176 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 177 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 178 }) |
| paddy@56 | 179 return |
| paddy@56 | 180 } |
| paddy@56 | 181 } else if redirectURI == "" && numEndpoints == 1 { |
| paddy@56 | 182 // if we don't specify the endpoint and there's only one endpoint, the |
| paddy@56 | 183 // request is valid, and we're redirecting to that one endpoint |
| paddy@56 | 184 validURI = true |
| paddy@56 | 185 endpoints, err := context.ListEndpoints(clientID, 1, 0) |
| paddy@56 | 186 if err != nil { |
| paddy@56 | 187 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 188 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 189 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 190 }) |
| paddy@56 | 191 return |
| paddy@56 | 192 } |
| paddy@56 | 193 if len(endpoints) != 1 { |
| paddy@56 | 194 validURI = false |
| paddy@56 | 195 } else { |
| paddy@66 | 196 u := endpoints[0].URI // Copy it here to avoid grabbing a pointer to the memstore |
| paddy@66 | 197 redirectURI = u.String() |
| paddy@66 | 198 redirectURL = &u |
| paddy@56 | 199 } |
| paddy@56 | 200 } else { |
| paddy@56 | 201 validURI = false |
| paddy@56 | 202 } |
| paddy@56 | 203 if !validURI { |
| paddy@56 | 204 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 205 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 206 "error": template.HTML("The redirect_uri specified is not valid."), |
| paddy@56 | 207 }) |
| paddy@56 | 208 return |
| paddy@56 | 209 } |
| paddy@60 | 210 scope := r.URL.Query().Get("scope") |
| paddy@60 | 211 state := r.URL.Query().Get("state") |
| paddy@56 | 212 if r.URL.Query().Get("response_type") != "code" { |
| paddy@65 | 213 q := redirectURL.Query() |
| paddy@65 | 214 q.Add("error", "invalid_request") |
| paddy@65 | 215 q.Add("state", state) |
| paddy@65 | 216 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 217 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 218 return |
| paddy@56 | 219 } |
| paddy@56 | 220 if r.Method == "POST" { |
| paddy@63 | 221 // BUG(paddy): We need to implement CSRF protection when obtaining a grant code. |
| paddy@56 | 222 if r.PostFormValue("grant") == "approved" { |
| paddy@60 | 223 code := uuid.NewID().String() |
| paddy@60 | 224 grant := Grant{ |
| paddy@60 | 225 Code: code, |
| paddy@60 | 226 Created: time.Now(), |
| paddy@60 | 227 ExpiresIn: defaultGrantExpiration, |
| paddy@60 | 228 ClientID: clientID, |
| paddy@60 | 229 Scope: scope, |
| paddy@69 | 230 RedirectURI: r.URL.Query().Get("redirect_uri"), |
| paddy@60 | 231 State: state, |
| paddy@69 | 232 ProfileID: session.ProfileID, |
| paddy@60 | 233 } |
| paddy@60 | 234 err := context.SaveGrant(grant) |
| paddy@60 | 235 if err != nil { |
| paddy@66 | 236 q := redirectURL.Query() |
| paddy@66 | 237 q.Add("error", "server_error") |
| paddy@66 | 238 q.Add("state", state) |
| paddy@66 | 239 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 240 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 241 return |
| paddy@60 | 242 } |
| paddy@66 | 243 q := redirectURL.Query() |
| paddy@66 | 244 q.Add("code", code) |
| paddy@66 | 245 q.Add("state", state) |
| paddy@66 | 246 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 247 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 248 return |
| paddy@56 | 249 } |
| paddy@66 | 250 q := redirectURL.Query() |
| paddy@66 | 251 q.Add("error", "access_denied") |
| paddy@66 | 252 q.Add("state", state) |
| paddy@66 | 253 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 254 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 255 return |
| paddy@56 | 256 } |
| paddy@51 | 257 w.WriteHeader(http.StatusOK) |
| paddy@56 | 258 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@56 | 259 "client": client, |
| paddy@56 | 260 }) |
| paddy@51 | 261 } |
| paddy@68 | 262 |
| paddy@69 | 263 // GetTokenHandler allows a client to exchange an authorization grant for an |
| paddy@69 | 264 // access token. See RFC 6749 Section 4.1.3. |
| paddy@69 | 265 func GetTokenHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@69 | 266 enc := json.NewEncoder(w) |
| paddy@69 | 267 grantType := r.PostFormValue("grant_type") |
| paddy@69 | 268 if grantType != "authorization_code" { |
| paddy@69 | 269 // TODO(paddy): render invalid request JSON |
| paddy@69 | 270 return |
| paddy@69 | 271 } |
| paddy@69 | 272 code := r.PostFormValue("code") |
| paddy@69 | 273 if code == "" { |
| paddy@69 | 274 // TODO(paddy): render invalid request JSON |
| paddy@69 | 275 return |
| paddy@69 | 276 } |
| paddy@69 | 277 redirectURI := r.PostFormValue("redirect_uri") |
| paddy@69 | 278 clientIDStr, clientSecret, err := getBasicAuth(r) |
| paddy@69 | 279 if err != nil { |
| paddy@69 | 280 // TODO(paddy): render access denied |
| paddy@69 | 281 return |
| paddy@69 | 282 } |
| paddy@69 | 283 if clientIDStr == "" && err == nil { |
| paddy@69 | 284 clientIDStr = r.PostFormValue("client_id") |
| paddy@69 | 285 } |
| paddy@69 | 286 clientID, err := uuid.Parse(clientIDStr) |
| paddy@69 | 287 if err != nil { |
| paddy@69 | 288 // TODO(paddy): render invalid request JSON |
| paddy@69 | 289 return |
| paddy@69 | 290 } |
| paddy@69 | 291 client, err := context.GetClient(clientID) |
| paddy@69 | 292 if err != nil { |
| paddy@69 | 293 if err == ErrClientNotFound { |
| paddy@69 | 294 // TODO(paddy): render invalid request JSON |
| paddy@69 | 295 } else { |
| paddy@69 | 296 // TODO(paddy): render internal server error JSON |
| paddy@69 | 297 } |
| paddy@69 | 298 return |
| paddy@69 | 299 } |
| paddy@69 | 300 if client.Secret != clientSecret { |
| paddy@69 | 301 // TODO(paddy): render invalid request JSON |
| paddy@69 | 302 return |
| paddy@69 | 303 } |
| paddy@69 | 304 grant, err := context.GetGrant(code) |
| paddy@69 | 305 if err != nil { |
| paddy@69 | 306 if err == ErrGrantNotFound { |
| paddy@69 | 307 // TODO(paddy): return error |
| paddy@69 | 308 return |
| paddy@69 | 309 } |
| paddy@69 | 310 // TODO(paddy): return error |
| paddy@69 | 311 } |
| paddy@69 | 312 if grant.RedirectURI != redirectURI { |
| paddy@69 | 313 // TODO(paddy): return error |
| paddy@69 | 314 } |
| paddy@69 | 315 if !grant.ClientID.Equal(clientID) { |
| paddy@69 | 316 // TODO(paddy): return error |
| paddy@69 | 317 } |
| paddy@69 | 318 token := Token{ |
| paddy@69 | 319 AccessToken: uuid.NewID().String(), |
| paddy@69 | 320 RefreshToken: uuid.NewID().String(), |
| paddy@69 | 321 Created: time.Now(), |
| paddy@69 | 322 ExpiresIn: defaultTokenExpiration, |
| paddy@69 | 323 TokenType: "", // TODO(paddy): fill in token type |
| paddy@69 | 324 Scope: grant.Scope, |
| paddy@69 | 325 ProfileID: grant.ProfileID, |
| paddy@69 | 326 } |
| paddy@69 | 327 err = context.SaveToken(token) |
| paddy@69 | 328 if err != nil { |
| paddy@69 | 329 // TODO(paddy): return error |
| paddy@69 | 330 } |
| paddy@69 | 331 resp := tokenResponse{ |
| paddy@69 | 332 AccessToken: token.AccessToken, |
| paddy@69 | 333 RefreshToken: token.RefreshToken, |
| paddy@69 | 334 ExpiresIn: token.ExpiresIn, |
| paddy@69 | 335 TokenType: token.TokenType, |
| paddy@69 | 336 } |
| paddy@69 | 337 err = enc.Encode(resp) |
| paddy@69 | 338 if err != nil { |
| paddy@69 | 339 // TODO(paddy): log this or something |
| paddy@69 | 340 return |
| paddy@69 | 341 } |
| paddy@69 | 342 } |
| paddy@69 | 343 |
| paddy@68 | 344 // TODO(paddy): exchange user credentials for access token |
| paddy@68 | 345 // TODO(paddy): exchange client credentials for access token |
| paddy@68 | 346 // TODO(paddy): implicit grant for access token |
| paddy@68 | 347 // TODO(paddy): exchange refresh token for access token |