auth

Paddy 2015-04-11 Parent:026adb0c7fc4 Child:6f473576c6ae

159:cf6c1f05eb21 Go to Latest

auth/session.go

Enable terminating sessions through the API. Add a terminateSession method to the sessionStore that sets the Active property of the Session to false. Create a Context.TerminateSession wrapper for the terminateSession method on the sessionStore. Add a Sessions property to our response type so we can return a []Session in API responses. Use the URL-safe encoding when base64 encoding our session ID and CSRFToken, so the ID can be passed in the URL and so our encodings are consistent. Add a TerminateSessionHandler function that will extract a Session ID from the request URL, authenticate the user, check that the authenticated user owns the session in question, and terminate the session. Add implementations for our new terminateSession method for the memstore and postgres types. Test both the memstore and postgres implementation of our terminateSession helper in session_test.go.

History
paddy@70 1 package auth
paddy@70 2
paddy@70 3 import (
paddy@132 4 "crypto/rand"
paddy@98 5 "crypto/sha256"
paddy@132 6 "encoding/base64"
paddy@98 7 "encoding/hex"
paddy@119 8 "encoding/json"
paddy@70 9 "errors"
paddy@98 10 "log"
paddy@98 11 "net/http"
paddy@89 12 "sort"
paddy@135 13 "strings"
paddy@70 14 "time"
paddy@70 15
paddy@107 16 "code.secondbit.org/pass.hg"
paddy@107 17 "code.secondbit.org/uuid.hg"
paddy@98 18 "github.com/gorilla/mux"
paddy@98 19 )
paddy@98 20
paddy@98 21 const (
paddy@132 22 authCookieName = "auth"
paddy@98 23 loginTemplateName = "login"
paddy@70 24 )
paddy@70 25
paddy@119 26 func init() {
paddy@119 27 RegisterGrantType("password", GrantType{
paddy@119 28 Validate: credentialsValidate,
paddy@119 29 Invalidate: nil,
paddy@119 30 IssuesRefresh: true,
paddy@119 31 ReturnToken: RenderJSONToken,
paddy@124 32 AuditString: credentialsAuditString,
paddy@119 33 })
paddy@119 34 }
paddy@119 35
paddy@70 36 var (
paddy@70 37 // ErrNoSessionStore is returned when a Context tries to act on a sessionStore without setting on first.
paddy@70 38 ErrNoSessionStore = errors.New("no sessionStore was specified for the Context")
paddy@70 39 // ErrSessionNotFound is returned when a Session is requested but not found in the sessionStore.
paddy@70 40 ErrSessionNotFound = errors.New("session not found in sessionStore")
paddy@70 41 // ErrInvalidSession is returned when a Session is specified but is not valid.
paddy@70 42 ErrInvalidSession = errors.New("session is not valid")
paddy@77 43 // ErrSessionAlreadyExists is returned when a sessionStore tries to store a Session with an ID that already exists in the sessionStore.
paddy@77 44 ErrSessionAlreadyExists = errors.New("session already exists")
paddy@132 45 // ErrCSRFAttempt is returned when a CSRF attempt is detected.
paddy@132 46 ErrCSRFAttempt = errors.New("CSRF attempt")
paddy@98 47
paddy@98 48 passphraseSchemes = map[int]passphraseScheme{
paddy@98 49 1: {
paddy@98 50 check: pbkdf2sha256check,
paddy@98 51 create: pbkdf2sha256create,
paddy@98 52 calculateIterations: pbkdf2sha256calc,
paddy@98 53 },
paddy@98 54 }
paddy@70 55 )
paddy@70 56
paddy@98 57 type passphraseScheme struct {
paddy@98 58 check func(profile Profile, passphrase string) (bool, error)
paddy@103 59 create func(passphrase string, iterations int) (result, salt string, err error)
paddy@98 60 calculateIterations func() (int, error)
paddy@98 61 }
paddy@98 62
paddy@70 63 // Session represents a user's authenticated session, associating it with a profile
paddy@70 64 // and some audit data.
paddy@70 65 type Session struct {
paddy@70 66 ID string
paddy@70 67 IP string
paddy@70 68 UserAgent string
paddy@70 69 ProfileID uuid.ID
paddy@98 70 Login string
paddy@70 71 Created time.Time
paddy@132 72 Expires time.Time
paddy@70 73 Active bool
paddy@132 74 CSRFToken string
paddy@70 75 }
paddy@70 76
paddy@89 77 type sortedSessions []Session
paddy@89 78
paddy@89 79 func (s sortedSessions) Len() int {
paddy@89 80 return len(s)
paddy@89 81 }
paddy@89 82
paddy@89 83 func (s sortedSessions) Less(i, j int) bool {
paddy@89 84 return s[i].Created.After(s[j].Created)
paddy@89 85 }
paddy@89 86
paddy@89 87 func (s sortedSessions) Swap(i, j int) {
paddy@89 88 s[i], s[j] = s[j], s[i]
paddy@89 89 }
paddy@89 90
paddy@70 91 type sessionStore interface {
paddy@70 92 createSession(session Session) error
paddy@70 93 getSession(id string) (Session, error)
paddy@159 94 terminateSession(id string) error
paddy@70 95 removeSession(id string) error
paddy@70 96 listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error)
paddy@70 97 }
paddy@77 98
paddy@77 99 func (m *memstore) createSession(session Session) error {
paddy@77 100 m.sessionLock.Lock()
paddy@77 101 defer m.sessionLock.Unlock()
paddy@77 102 if _, ok := m.sessions[session.ID]; ok {
paddy@77 103 return ErrSessionAlreadyExists
paddy@77 104 }
paddy@77 105 m.sessions[session.ID] = session
paddy@77 106 return nil
paddy@77 107 }
paddy@77 108
paddy@77 109 func (m *memstore) getSession(id string) (Session, error) {
paddy@77 110 m.sessionLock.RLock()
paddy@77 111 defer m.sessionLock.RUnlock()
paddy@77 112 if _, ok := m.sessions[id]; !ok {
paddy@77 113 return Session{}, ErrSessionNotFound
paddy@77 114 }
paddy@77 115 return m.sessions[id], nil
paddy@77 116 }
paddy@77 117
paddy@159 118 func (m *memstore) terminateSession(id string) error {
paddy@159 119 m.sessionLock.RLock()
paddy@159 120 defer m.sessionLock.RUnlock()
paddy@159 121 sess, ok := m.sessions[id]
paddy@159 122 if !ok {
paddy@159 123 return ErrSessionNotFound
paddy@159 124 }
paddy@159 125 sess.Active = false
paddy@159 126 m.sessions[id] = sess
paddy@159 127 return nil
paddy@159 128 }
paddy@159 129
paddy@77 130 func (m *memstore) removeSession(id string) error {
paddy@77 131 m.sessionLock.Lock()
paddy@77 132 defer m.sessionLock.Unlock()
paddy@77 133 if _, ok := m.sessions[id]; !ok {
paddy@77 134 return ErrSessionNotFound
paddy@77 135 }
paddy@77 136 delete(m.sessions, id)
paddy@77 137 return nil
paddy@77 138 }
paddy@77 139
paddy@77 140 func (m *memstore) listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) {
paddy@77 141 m.sessionLock.RLock()
paddy@77 142 defer m.sessionLock.RUnlock()
paddy@77 143 res := []Session{}
paddy@77 144 for _, session := range m.sessions {
paddy@77 145 if int64(len(res)) >= num {
paddy@77 146 break
paddy@77 147 }
paddy@77 148 if profile != nil && !profile.Equal(session.ProfileID) {
paddy@77 149 continue
paddy@77 150 }
paddy@77 151 if !before.IsZero() && session.Created.After(before) {
paddy@77 152 continue
paddy@77 153 }
paddy@77 154 res = append(res, session)
paddy@77 155 }
paddy@89 156 sorted := sortedSessions(res)
paddy@89 157 sort.Sort(sorted)
paddy@89 158 res = []Session(sorted)
paddy@77 159 return res, nil
paddy@77 160 }
paddy@98 161
paddy@98 162 // RegisterSessionHandlers adds handlers to the passed router to handle the session endpoints, like login and logout.
paddy@98 163 func RegisterSessionHandlers(r *mux.Router, context Context) {
paddy@98 164 r.Handle("/login", wrap(context, CreateSessionHandler))
paddy@128 165 // BUG(paddy): We need to implement a handler for listing sessions active on a profile.
paddy@159 166 r.Handle("/sessions/{id}", wrap(context, TerminateSessionHandler)).Methods("OPTIONS", "DELETE")
paddy@98 167 }
paddy@98 168
paddy@132 169 func checkCSRF(r *http.Request, s Session) error {
paddy@132 170 if r.PostFormValue("csrftoken") != s.CSRFToken {
paddy@132 171 return ErrCSRFAttempt
paddy@132 172 }
paddy@132 173 return nil
paddy@132 174 }
paddy@132 175
paddy@98 176 func checkCookie(r *http.Request, context Context) (Session, error) {
paddy@98 177 cookie, err := r.Cookie(authCookieName)
paddy@98 178 if err == http.ErrNoCookie {
paddy@98 179 return Session{}, ErrNoSession
paddy@98 180 } else if err != nil {
paddy@98 181 log.Println(err)
paddy@98 182 return Session{}, err
paddy@98 183 }
paddy@98 184 sess, err := context.GetSession(cookie.Value)
paddy@98 185 if err == ErrSessionNotFound {
paddy@98 186 return Session{}, ErrInvalidSession
paddy@98 187 } else if err != nil {
paddy@98 188 return Session{}, err
paddy@98 189 }
paddy@98 190 if !sess.Active {
paddy@98 191 return Session{}, ErrInvalidSession
paddy@98 192 }
paddy@132 193 if time.Now().After(sess.Expires) {
paddy@132 194 return Session{}, ErrInvalidSession
paddy@132 195 }
paddy@98 196 return sess, nil
paddy@98 197 }
paddy@98 198
paddy@98 199 func buildLoginRedirect(r *http.Request, context Context) string {
paddy@98 200 if context.loginURI == nil {
paddy@98 201 return ""
paddy@98 202 }
paddy@98 203 uri := *context.loginURI
paddy@98 204 q := uri.Query()
paddy@98 205 q.Set("from", r.URL.String())
paddy@98 206 uri.RawQuery = q.Encode()
paddy@98 207 return uri.String()
paddy@98 208 }
paddy@98 209
paddy@98 210 func pbkdf2sha256check(profile Profile, passphrase string) (bool, error) {
paddy@98 211 realPass, err := hex.DecodeString(profile.Passphrase)
paddy@98 212 if err != nil {
paddy@98 213 return false, err
paddy@98 214 }
paddy@103 215 realSalt, err := hex.DecodeString(profile.Salt)
paddy@103 216 if err != nil {
paddy@103 217 return false, err
paddy@103 218 }
paddy@103 219 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(realSalt))
paddy@98 220 if !pass.Compare(candidate, realPass) {
paddy@98 221 return false, ErrIncorrectAuth
paddy@98 222 }
paddy@98 223 return true, nil
paddy@98 224 }
paddy@98 225
paddy@103 226 func pbkdf2sha256create(passphrase string, iters int) (result, salt string, err error) {
paddy@103 227 passBytes, saltBytes, err := pass.Create(sha256.New, iters, []byte(passphrase))
paddy@103 228 if err != nil {
paddy@103 229 return "", "", err
paddy@103 230 }
paddy@103 231 result = hex.EncodeToString(passBytes)
paddy@103 232 salt = hex.EncodeToString(saltBytes)
paddy@103 233 return result, salt, err
paddy@98 234 }
paddy@98 235
paddy@98 236 func pbkdf2sha256calc() (int, error) {
paddy@98 237 return pass.CalculateIterations(sha256.New)
paddy@98 238 }
paddy@98 239
paddy@139 240 func isAuthError(err error) bool {
paddy@139 241 return err == ErrIncorrectAuth || err == ErrProfileCompromised || err == ErrProfileLocked || err == ErrInvalidPassphraseScheme
paddy@139 242 }
paddy@139 243
paddy@98 244 func authenticate(user, passphrase string, context Context) (Profile, error) {
paddy@98 245 profile, err := context.GetProfileByLogin(user)
paddy@98 246 if err != nil {
paddy@98 247 if err == ErrProfileNotFound || err == ErrLoginNotFound {
paddy@98 248 return Profile{}, ErrIncorrectAuth
paddy@98 249 }
paddy@98 250 return Profile{}, err
paddy@98 251 }
paddy@98 252 if profile.Compromised {
paddy@98 253 return Profile{}, ErrProfileCompromised
paddy@98 254 }
paddy@98 255 if !profile.LockedUntil.IsZero() && profile.LockedUntil.After(time.Now()) {
paddy@98 256 return profile, ErrProfileLocked
paddy@98 257 }
paddy@98 258 scheme, ok := passphraseSchemes[profile.PassphraseScheme]
paddy@98 259 if !ok {
paddy@98 260 return Profile{}, ErrInvalidPassphraseScheme
paddy@98 261 }
paddy@98 262 result, err := scheme.check(profile, passphrase)
paddy@98 263 if !result {
paddy@98 264 return Profile{}, err
paddy@98 265 }
paddy@98 266 return profile, nil
paddy@98 267 }
paddy@98 268
paddy@98 269 // CreateSessionHandler allows the user to log into their account and create their session.
paddy@98 270 func CreateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) {
paddy@98 271 errors := []error{}
paddy@98 272 if r.Method == "POST" {
paddy@98 273 profile, err := authenticate(r.PostFormValue("login"), r.PostFormValue("passphrase"), context)
paddy@98 274 if err == nil {
paddy@98 275 ip := r.Header.Get("X-Forwarded-For")
paddy@98 276 if ip == "" {
paddy@98 277 ip = r.RemoteAddr
paddy@98 278 }
paddy@132 279 sessionID := make([]byte, 32)
paddy@132 280 csrfToken := make([]byte, 32)
paddy@132 281 _, err = rand.Read(sessionID)
paddy@132 282 if err != nil {
paddy@132 283 log.Println("Error reading CSPRNG for session ID:", err)
paddy@132 284 w.WriteHeader(http.StatusInternalServerError)
paddy@132 285 w.Write([]byte("Internal error"))
paddy@132 286 return
paddy@132 287 }
paddy@132 288 _, err = rand.Read(csrfToken)
paddy@132 289 if err != nil {
paddy@132 290 log.Println("Error reading CSPRNG for CSRF token:", err)
paddy@132 291 w.WriteHeader(http.StatusInternalServerError)
paddy@132 292 w.Write([]byte("internal error"))
paddy@132 293 return
paddy@132 294 }
paddy@98 295 session := Session{
paddy@159 296 ID: base64.URLEncoding.EncodeToString(sessionID),
paddy@98 297 IP: ip,
paddy@98 298 UserAgent: r.UserAgent(),
paddy@98 299 ProfileID: profile.ID,
paddy@98 300 Login: r.PostFormValue("login"),
paddy@98 301 Created: time.Now(),
paddy@132 302 Expires: time.Now().Add(time.Hour),
paddy@98 303 Active: true,
paddy@159 304 CSRFToken: base64.URLEncoding.EncodeToString(csrfToken),
paddy@98 305 }
paddy@98 306 err = context.CreateSession(session)
paddy@98 307 if err != nil {
paddy@98 308 w.WriteHeader(http.StatusInternalServerError)
paddy@98 309 w.Write([]byte(err.Error()))
paddy@98 310 return
paddy@98 311 }
paddy@132 312 // BUG(paddy): We really need to do a security audit on our cookies.
paddy@98 313 cookie := http.Cookie{
paddy@98 314 Name: authCookieName,
paddy@98 315 Value: session.ID,
paddy@132 316 Expires: session.Expires,
paddy@98 317 HttpOnly: true,
paddy@132 318 Secure: context.config.secureCookie,
paddy@98 319 }
paddy@98 320 http.SetCookie(w, &cookie)
paddy@98 321 redirectTo := r.URL.Query().Get("from")
paddy@98 322 if redirectTo == "" {
paddy@98 323 redirectTo = "/"
paddy@98 324 }
paddy@98 325 http.Redirect(w, r, redirectTo, http.StatusFound)
paddy@98 326 return
paddy@139 327 } else if !isAuthError(err) {
paddy@98 328 w.WriteHeader(http.StatusInternalServerError)
paddy@98 329 w.Write([]byte(err.Error()))
paddy@98 330 return
paddy@98 331 } else {
paddy@98 332 errors = append(errors, err)
paddy@98 333 }
paddy@98 334 }
paddy@98 335 context.Render(w, loginTemplateName, map[string]interface{}{
paddy@98 336 "errors": errors,
paddy@98 337 })
paddy@98 338 }
paddy@119 339
paddy@159 340 // TerminateSessionHandler allows the user to end their session before it expires.
paddy@159 341 func TerminateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) {
paddy@159 342 var errors []requestError
paddy@159 343 vars := mux.Vars(r)
paddy@159 344 if vars["id"] == "" {
paddy@159 345 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
paddy@159 346 encode(w, r, http.StatusBadRequest, response{Errors: errors})
paddy@159 347 return
paddy@159 348 }
paddy@159 349 id := vars["id"]
paddy@159 350 un, pw, ok := r.BasicAuth()
paddy@159 351 if !ok {
paddy@159 352 errors = append(errors, requestError{Slug: requestErrAccessDenied})
paddy@159 353 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
paddy@159 354 return
paddy@159 355 }
paddy@159 356 profile, err := authenticate(un, pw, context)
paddy@159 357 if err != nil {
paddy@159 358 if isAuthError(err) {
paddy@159 359 errors = append(errors, requestError{Slug: requestErrAccessDenied})
paddy@159 360 encode(w, r, http.StatusForbidden, response{Errors: errors})
paddy@159 361 return
paddy@159 362 }
paddy@159 363 errors = append(errors, requestError{Slug: requestErrActOfGod})
paddy@159 364 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
paddy@159 365 return
paddy@159 366 }
paddy@159 367 session, err := context.GetSession(id)
paddy@159 368 if err != nil {
paddy@159 369 if err == ErrSessionNotFound {
paddy@159 370 errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
paddy@159 371 encode(w, r, http.StatusNotFound, response{Errors: errors})
paddy@159 372 return
paddy@159 373 }
paddy@159 374 errors = append(errors, requestError{Slug: requestErrActOfGod})
paddy@159 375 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
paddy@159 376 return
paddy@159 377 }
paddy@159 378 if !session.ProfileID.Equal(profile.ID) {
paddy@159 379 errors = append(errors, requestError{Slug: requestErrAccessDenied, Param: "id"})
paddy@159 380 encode(w, r, http.StatusForbidden, response{Errors: errors})
paddy@159 381 return
paddy@159 382 }
paddy@159 383 err = context.TerminateSession(id)
paddy@159 384 if err != nil {
paddy@159 385 if err == ErrSessionNotFound {
paddy@159 386 errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
paddy@159 387 encode(w, r, http.StatusNotFound, response{Errors: errors})
paddy@159 388 return
paddy@159 389 }
paddy@159 390 errors = append(errors, requestError{Slug: requestErrActOfGod})
paddy@159 391 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
paddy@159 392 return
paddy@159 393 }
paddy@159 394 session.Active = false
paddy@159 395 encode(w, r, http.StatusOK, response{Sessions: []Session{session}, Errors: errors})
paddy@159 396 }
paddy@159 397
paddy@135 398 func credentialsValidate(w http.ResponseWriter, r *http.Request, context Context) (scopes []string, profileID uuid.ID, valid bool) {
paddy@119 399 enc := json.NewEncoder(w)
paddy@119 400 username := r.PostFormValue("username")
paddy@119 401 password := r.PostFormValue("password")
paddy@135 402 scopes = strings.Split(r.PostFormValue("scope"), " ")
paddy@119 403 profile, err := authenticate(username, password, context)
paddy@119 404 if err != nil {
paddy@139 405 if isAuthError(err) {
paddy@119 406 w.WriteHeader(http.StatusBadRequest)
paddy@119 407 renderJSONError(enc, "invalid_grant")
paddy@119 408 return
paddy@119 409 }
paddy@119 410 w.WriteHeader(http.StatusInternalServerError)
paddy@119 411 w.Write([]byte(err.Error()))
paddy@119 412 return
paddy@119 413 }
paddy@119 414 profileID = profile.ID
paddy@119 415 valid = true
paddy@119 416 return
paddy@119 417 }
paddy@124 418
paddy@124 419 func credentialsAuditString(r *http.Request) string {
paddy@124 420 return "credentials"
paddy@124 421 }