auth
auth/http.go
Stub out sessions. Stop using the Login type when getting profile by Login, removing Logins, or recording Login use. The Login value has to be unique, anyways, and we don't actually know the Login type when getting a profile by Login. That's sort of the point. Create the concept of Sessions and a sessionStore type to manage our authentication sessions with the server. As per OWASP, we're basically just going to use a transparent, SHA256-generated random string as an ID, and store it client-side and server-side and just pass it back and forth. Add the ProfileID to the Grant type, because we need to remember who granted access. That's sort of important. Set a defaultGrantExpiration constant to an hour, so we have that one constant when creating new Grants. Create a helper that pulls the session ID out of an auth cookie, checks it against the sessionStore, and returns the Session if it's valid. Create a helper that pulls the username and password out of a basic auth header. Create a helper that authenticates a user's login and passphrase, checking them against the profileStore securely. Stub out how the cookie checking is going to work for getting grant approval. Fix the stored Grant RedirectURI to be the passed in redirect URI, not the RedirectURI that we ultimately redirect to. This is in accordance with the spec. Store the profile ID from our session in the created Grant. Stub out a GetTokenHandler that will allow users to exchange a Grant for a Token. Set a constant for the current passphrase scheme, which we will increment for each revision to the passphrase scheme, for backwards compatibility. Change the Profile iterations property to an int, not an int64, to match the code.secondbit.org/pass library (which is matching the PBKDF2 library).
| 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 // TODO(paddy): should probably log this... |
| paddy@69 | 56 return "", "", ErrInvalidAuthFormat |
| paddy@69 | 57 } |
| paddy@69 | 58 info := strings.SplitN(string(decoded), ":", 2) |
| paddy@69 | 59 return info[0], info[1], nil |
| paddy@69 | 60 } |
| paddy@69 | 61 |
| paddy@69 | 62 func checkCookie(r *http.Request, context Context) (Session, error) { |
| paddy@69 | 63 cookie, err := r.Cookie(authCookieName) |
| paddy@69 | 64 if err != nil { |
| paddy@69 | 65 if err == http.ErrNoCookie { |
| paddy@69 | 66 return Session{}, ErrNoSession |
| paddy@69 | 67 } |
| paddy@69 | 68 return Session{}, err |
| paddy@69 | 69 } |
| paddy@69 | 70 if cookie.Name != authCookieName || !cookie.Expires.After(time.Now()) || |
| paddy@69 | 71 !cookie.Secure || !cookie.HttpOnly { |
| paddy@69 | 72 return Session{}, ErrInvalidSession |
| paddy@69 | 73 } |
| paddy@69 | 74 sess, err := context.GetSession(cookie.Value) |
| paddy@69 | 75 if err == ErrSessionNotFound { |
| paddy@69 | 76 return Session{}, ErrInvalidSession |
| paddy@69 | 77 } else if err != nil { |
| paddy@69 | 78 return Session{}, err |
| paddy@69 | 79 } |
| paddy@69 | 80 if !sess.Active { |
| paddy@69 | 81 return Session{}, ErrInvalidSession |
| paddy@69 | 82 } |
| paddy@69 | 83 return sess, nil |
| paddy@69 | 84 } |
| paddy@69 | 85 |
| paddy@69 | 86 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@69 | 87 profile, err := context.GetProfileByLogin(user) |
| paddy@69 | 88 if err != nil { |
| paddy@69 | 89 if err == ErrProfileNotFound { |
| paddy@69 | 90 return Profile{}, ErrIncorrectAuth |
| paddy@69 | 91 } |
| paddy@69 | 92 return Profile{}, err |
| paddy@69 | 93 } |
| paddy@69 | 94 switch profile.PassphraseScheme { |
| paddy@69 | 95 case 1: |
| paddy@69 | 96 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(profile.Salt)) |
| paddy@69 | 97 if !pass.Compare(candidate, []byte(profile.Passphrase)) { |
| paddy@69 | 98 return Profile{}, ErrIncorrectAuth |
| paddy@69 | 99 } |
| paddy@69 | 100 default: |
| paddy@69 | 101 // TODO(paddy): return some error |
| paddy@69 | 102 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@69 | 103 } |
| paddy@69 | 104 return profile, nil |
| paddy@69 | 105 } |
| paddy@69 | 106 |
| paddy@57 | 107 // GetGrantHandler presents and processes the page for asking a user to grant access |
| paddy@57 | 108 // to their data. See RFC 6749, Section 4.1. |
| paddy@51 | 109 func GetGrantHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@69 | 110 session, err := checkCookie(r, context) |
| paddy@69 | 111 if err != nil { |
| paddy@69 | 112 if err == ErrNoSession { |
| paddy@69 | 113 // TODO(paddy): redirect to login screen |
| paddy@69 | 114 //return |
| paddy@69 | 115 } |
| paddy@69 | 116 if err == ErrInvalidSession { |
| paddy@69 | 117 // TODO(paddy): return an access denied error |
| paddy@69 | 118 //return |
| paddy@69 | 119 } |
| paddy@69 | 120 // TODO(paddy): return a server error |
| paddy@69 | 121 //return |
| paddy@69 | 122 } |
| paddy@56 | 123 if r.URL.Query().Get("client_id") == "" { |
| paddy@56 | 124 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 125 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 126 "error": template.HTML("Client ID must be specified in the request."), |
| paddy@56 | 127 }) |
| paddy@56 | 128 return |
| paddy@56 | 129 } |
| paddy@56 | 130 clientID, err := uuid.Parse(r.URL.Query().Get("client_id")) |
| paddy@56 | 131 if err != nil { |
| paddy@56 | 132 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 133 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 134 "error": template.HTML("client_id is not a valid Client ID."), |
| paddy@56 | 135 }) |
| paddy@56 | 136 return |
| paddy@56 | 137 } |
| paddy@64 | 138 redirectURI := r.URL.Query().Get("redirect_uri") |
| paddy@64 | 139 redirectURL, err := url.Parse(redirectURI) |
| paddy@64 | 140 if err != nil { |
| paddy@64 | 141 w.WriteHeader(http.StatusBadRequest) |
| paddy@64 | 142 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@64 | 143 "error": template.HTML("The redirect_uri specified is not valid."), |
| paddy@64 | 144 }) |
| paddy@64 | 145 return |
| paddy@64 | 146 } |
| paddy@56 | 147 client, err := context.GetClient(clientID) |
| paddy@56 | 148 if err != nil { |
| paddy@59 | 149 if err == ErrClientNotFound { |
| paddy@59 | 150 w.WriteHeader(http.StatusBadRequest) |
| paddy@59 | 151 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 152 "error": template.HTML("The specified Client couldn’t be found."), |
| paddy@59 | 153 }) |
| paddy@59 | 154 } else { |
| paddy@59 | 155 w.WriteHeader(http.StatusInternalServerError) |
| paddy@59 | 156 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 157 "internal_error": template.HTML(err.Error()), |
| paddy@59 | 158 }) |
| paddy@59 | 159 } |
| paddy@56 | 160 return |
| paddy@56 | 161 } |
| paddy@56 | 162 // whether a redirect URI is valid or not depends on the number of endpoints |
| paddy@56 | 163 // the client has registered |
| paddy@56 | 164 numEndpoints, err := context.CountEndpoints(clientID) |
| paddy@56 | 165 if err != nil { |
| paddy@56 | 166 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 167 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 168 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 169 }) |
| paddy@56 | 170 return |
| paddy@56 | 171 } |
| paddy@56 | 172 var validURI bool |
| paddy@58 | 173 if redirectURI != "" { |
| paddy@58 | 174 // BUG(paddy): We really should normalize URIs before trying to compare them. |
| paddy@58 | 175 validURI, err = context.CheckEndpoint(clientID, redirectURI) |
| paddy@56 | 176 if err != nil { |
| paddy@56 | 177 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 178 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 179 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 180 }) |
| paddy@56 | 181 return |
| paddy@56 | 182 } |
| paddy@56 | 183 } else if redirectURI == "" && numEndpoints == 1 { |
| paddy@56 | 184 // if we don't specify the endpoint and there's only one endpoint, the |
| paddy@56 | 185 // request is valid, and we're redirecting to that one endpoint |
| paddy@56 | 186 validURI = true |
| paddy@56 | 187 endpoints, err := context.ListEndpoints(clientID, 1, 0) |
| paddy@56 | 188 if err != nil { |
| paddy@56 | 189 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 190 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 191 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 192 }) |
| paddy@56 | 193 return |
| paddy@56 | 194 } |
| paddy@56 | 195 if len(endpoints) != 1 { |
| paddy@56 | 196 validURI = false |
| paddy@56 | 197 } else { |
| paddy@66 | 198 u := endpoints[0].URI // Copy it here to avoid grabbing a pointer to the memstore |
| paddy@66 | 199 redirectURI = u.String() |
| paddy@66 | 200 redirectURL = &u |
| paddy@56 | 201 } |
| paddy@56 | 202 } else { |
| paddy@56 | 203 validURI = false |
| paddy@56 | 204 } |
| paddy@56 | 205 if !validURI { |
| paddy@56 | 206 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 207 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 208 "error": template.HTML("The redirect_uri specified is not valid."), |
| paddy@56 | 209 }) |
| paddy@56 | 210 return |
| paddy@56 | 211 } |
| paddy@60 | 212 scope := r.URL.Query().Get("scope") |
| paddy@60 | 213 state := r.URL.Query().Get("state") |
| paddy@56 | 214 if r.URL.Query().Get("response_type") != "code" { |
| paddy@65 | 215 q := redirectURL.Query() |
| paddy@65 | 216 q.Add("error", "invalid_request") |
| paddy@65 | 217 q.Add("state", state) |
| paddy@65 | 218 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 219 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 220 return |
| paddy@56 | 221 } |
| paddy@56 | 222 if r.Method == "POST" { |
| paddy@63 | 223 // BUG(paddy): We need to implement CSRF protection when obtaining a grant code. |
| paddy@56 | 224 if r.PostFormValue("grant") == "approved" { |
| paddy@60 | 225 code := uuid.NewID().String() |
| paddy@60 | 226 grant := Grant{ |
| paddy@60 | 227 Code: code, |
| paddy@60 | 228 Created: time.Now(), |
| paddy@60 | 229 ExpiresIn: defaultGrantExpiration, |
| paddy@60 | 230 ClientID: clientID, |
| paddy@60 | 231 Scope: scope, |
| paddy@69 | 232 RedirectURI: r.URL.Query().Get("redirect_uri"), |
| paddy@60 | 233 State: state, |
| paddy@69 | 234 ProfileID: session.ProfileID, |
| paddy@60 | 235 } |
| paddy@60 | 236 err := context.SaveGrant(grant) |
| paddy@60 | 237 if err != nil { |
| paddy@66 | 238 q := redirectURL.Query() |
| paddy@66 | 239 q.Add("error", "server_error") |
| paddy@66 | 240 q.Add("state", state) |
| paddy@66 | 241 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 242 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 243 return |
| paddy@60 | 244 } |
| paddy@66 | 245 q := redirectURL.Query() |
| paddy@66 | 246 q.Add("code", code) |
| paddy@66 | 247 q.Add("state", state) |
| paddy@66 | 248 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 249 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 250 return |
| paddy@56 | 251 } |
| paddy@66 | 252 q := redirectURL.Query() |
| paddy@66 | 253 q.Add("error", "access_denied") |
| paddy@66 | 254 q.Add("state", state) |
| paddy@66 | 255 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 256 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 257 return |
| paddy@56 | 258 } |
| paddy@51 | 259 w.WriteHeader(http.StatusOK) |
| paddy@56 | 260 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@56 | 261 "client": client, |
| paddy@56 | 262 }) |
| paddy@51 | 263 } |
| paddy@68 | 264 |
| paddy@69 | 265 // GetTokenHandler allows a client to exchange an authorization grant for an |
| paddy@69 | 266 // access token. See RFC 6749 Section 4.1.3. |
| paddy@69 | 267 func GetTokenHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@69 | 268 enc := json.NewEncoder(w) |
| paddy@69 | 269 grantType := r.PostFormValue("grant_type") |
| paddy@69 | 270 if grantType != "authorization_code" { |
| paddy@69 | 271 // TODO(paddy): render invalid request JSON |
| paddy@69 | 272 return |
| paddy@69 | 273 } |
| paddy@69 | 274 code := r.PostFormValue("code") |
| paddy@69 | 275 if code == "" { |
| paddy@69 | 276 // TODO(paddy): render invalid request JSON |
| paddy@69 | 277 return |
| paddy@69 | 278 } |
| paddy@69 | 279 redirectURI := r.PostFormValue("redirect_uri") |
| paddy@69 | 280 clientIDStr, clientSecret, err := getBasicAuth(r) |
| paddy@69 | 281 if err != nil { |
| paddy@69 | 282 // TODO(paddy): render access denied |
| paddy@69 | 283 return |
| paddy@69 | 284 } |
| paddy@69 | 285 if clientIDStr == "" && err == nil { |
| paddy@69 | 286 clientIDStr = r.PostFormValue("client_id") |
| paddy@69 | 287 } |
| paddy@69 | 288 // TODO(paddy): client ID can also come from Basic auth |
| paddy@69 | 289 clientID, err := uuid.Parse(clientIDStr) |
| paddy@69 | 290 if err != nil { |
| paddy@69 | 291 // TODO(paddy): render invalid request JSON |
| paddy@69 | 292 return |
| paddy@69 | 293 } |
| paddy@69 | 294 client, err := context.GetClient(clientID) |
| paddy@69 | 295 if err != nil { |
| paddy@69 | 296 if err == ErrClientNotFound { |
| paddy@69 | 297 // TODO(paddy): render invalid request JSON |
| paddy@69 | 298 } else { |
| paddy@69 | 299 // TODO(paddy): render internal server error JSON |
| paddy@69 | 300 } |
| paddy@69 | 301 return |
| paddy@69 | 302 } |
| paddy@69 | 303 if client.Secret != clientSecret { |
| paddy@69 | 304 // TODO(paddy): render invalid request JSON |
| paddy@69 | 305 return |
| paddy@69 | 306 } |
| paddy@69 | 307 grant, err := context.GetGrant(code) |
| paddy@69 | 308 if err != nil { |
| paddy@69 | 309 if err == ErrGrantNotFound { |
| paddy@69 | 310 // TODO(paddy): return error |
| paddy@69 | 311 return |
| paddy@69 | 312 } |
| paddy@69 | 313 // TODO(paddy): return error |
| paddy@69 | 314 } |
| paddy@69 | 315 if grant.RedirectURI != redirectURI { |
| paddy@69 | 316 // TODO(paddy): return error |
| paddy@69 | 317 } |
| paddy@69 | 318 if !grant.ClientID.Equal(clientID) { |
| paddy@69 | 319 // TODO(paddy): return error |
| paddy@69 | 320 } |
| paddy@69 | 321 token := Token{ |
| paddy@69 | 322 AccessToken: uuid.NewID().String(), |
| paddy@69 | 323 RefreshToken: uuid.NewID().String(), |
| paddy@69 | 324 Created: time.Now(), |
| paddy@69 | 325 ExpiresIn: defaultTokenExpiration, |
| paddy@69 | 326 TokenType: "", // TODO(paddy): fill in token type |
| paddy@69 | 327 Scope: grant.Scope, |
| paddy@69 | 328 ProfileID: grant.ProfileID, |
| paddy@69 | 329 } |
| paddy@69 | 330 err = context.SaveToken(token) |
| paddy@69 | 331 if err != nil { |
| paddy@69 | 332 // TODO(paddy): return error |
| paddy@69 | 333 } |
| paddy@69 | 334 resp := tokenResponse{ |
| paddy@69 | 335 AccessToken: token.AccessToken, |
| paddy@69 | 336 RefreshToken: token.RefreshToken, |
| paddy@69 | 337 ExpiresIn: token.ExpiresIn, |
| paddy@69 | 338 TokenType: token.TokenType, |
| paddy@69 | 339 } |
| paddy@69 | 340 err = enc.Encode(resp) |
| paddy@69 | 341 if err != nil { |
| paddy@69 | 342 // TODO(paddy): log this or something |
| paddy@69 | 343 return |
| paddy@69 | 344 } |
| paddy@69 | 345 } |
| paddy@69 | 346 |
| paddy@68 | 347 // TODO(paddy): exchange user credentials for access token |
| paddy@68 | 348 // TODO(paddy): exchange client credentials for access token |
| paddy@68 | 349 // TODO(paddy): implicit grant for access token |
| paddy@68 | 350 // TODO(paddy): exchange refresh token for access token |