auth
auth/session.go
Test our getClientAuth helper, switch to table-based tests. Our getClientAuth helper was being tested implicitly when we tested our verifyClient helper, but let's test them separately. While we're at it, let's use table based tests instead of copy and paste. I noticed a lot of copy/paste errors while I was updating this, and the less test code we have and the easier we make it to test new edge cases, the better of we are.
| paddy@70 | 1 package auth |
| paddy@70 | 2 |
| paddy@70 | 3 import ( |
| paddy@98 | 4 "crypto/sha256" |
| paddy@98 | 5 "encoding/hex" |
| paddy@119 | 6 "encoding/json" |
| paddy@70 | 7 "errors" |
| paddy@98 | 8 "log" |
| paddy@98 | 9 "net/http" |
| paddy@89 | 10 "sort" |
| paddy@70 | 11 "time" |
| paddy@70 | 12 |
| paddy@107 | 13 "code.secondbit.org/pass.hg" |
| paddy@107 | 14 "code.secondbit.org/uuid.hg" |
| paddy@98 | 15 "github.com/gorilla/mux" |
| paddy@98 | 16 ) |
| paddy@98 | 17 |
| paddy@98 | 18 const ( |
| paddy@98 | 19 loginTemplateName = "login" |
| paddy@70 | 20 ) |
| paddy@70 | 21 |
| paddy@119 | 22 func init() { |
| paddy@119 | 23 RegisterGrantType("password", GrantType{ |
| paddy@119 | 24 Validate: credentialsValidate, |
| paddy@119 | 25 Invalidate: nil, |
| paddy@119 | 26 IssuesRefresh: true, |
| paddy@119 | 27 ReturnToken: RenderJSONToken, |
| paddy@124 | 28 AuditString: credentialsAuditString, |
| paddy@119 | 29 }) |
| paddy@119 | 30 } |
| paddy@119 | 31 |
| paddy@70 | 32 var ( |
| paddy@70 | 33 // ErrNoSessionStore is returned when a Context tries to act on a sessionStore without setting on first. |
| paddy@70 | 34 ErrNoSessionStore = errors.New("no sessionStore was specified for the Context") |
| paddy@70 | 35 // ErrSessionNotFound is returned when a Session is requested but not found in the sessionStore. |
| paddy@70 | 36 ErrSessionNotFound = errors.New("session not found in sessionStore") |
| paddy@70 | 37 // ErrInvalidSession is returned when a Session is specified but is not valid. |
| paddy@70 | 38 ErrInvalidSession = errors.New("session is not valid") |
| paddy@77 | 39 // ErrSessionAlreadyExists is returned when a sessionStore tries to store a Session with an ID that already exists in the sessionStore. |
| paddy@77 | 40 ErrSessionAlreadyExists = errors.New("session already exists") |
| paddy@98 | 41 |
| paddy@98 | 42 passphraseSchemes = map[int]passphraseScheme{ |
| paddy@98 | 43 1: { |
| paddy@98 | 44 check: pbkdf2sha256check, |
| paddy@98 | 45 create: pbkdf2sha256create, |
| paddy@98 | 46 calculateIterations: pbkdf2sha256calc, |
| paddy@98 | 47 }, |
| paddy@98 | 48 } |
| paddy@70 | 49 ) |
| paddy@70 | 50 |
| paddy@98 | 51 type passphraseScheme struct { |
| paddy@98 | 52 check func(profile Profile, passphrase string) (bool, error) |
| paddy@103 | 53 create func(passphrase string, iterations int) (result, salt string, err error) |
| paddy@98 | 54 calculateIterations func() (int, error) |
| paddy@98 | 55 } |
| paddy@98 | 56 |
| paddy@70 | 57 // Session represents a user's authenticated session, associating it with a profile |
| paddy@70 | 58 // and some audit data. |
| paddy@70 | 59 type Session struct { |
| paddy@70 | 60 ID string |
| paddy@70 | 61 IP string |
| paddy@70 | 62 UserAgent string |
| paddy@70 | 63 ProfileID uuid.ID |
| paddy@98 | 64 Login string |
| paddy@70 | 65 Created time.Time |
| paddy@70 | 66 Active bool |
| paddy@70 | 67 } |
| paddy@70 | 68 |
| paddy@89 | 69 type sortedSessions []Session |
| paddy@89 | 70 |
| paddy@89 | 71 func (s sortedSessions) Len() int { |
| paddy@89 | 72 return len(s) |
| paddy@89 | 73 } |
| paddy@89 | 74 |
| paddy@89 | 75 func (s sortedSessions) Less(i, j int) bool { |
| paddy@89 | 76 return s[i].Created.After(s[j].Created) |
| paddy@89 | 77 } |
| paddy@89 | 78 |
| paddy@89 | 79 func (s sortedSessions) Swap(i, j int) { |
| paddy@89 | 80 s[i], s[j] = s[j], s[i] |
| paddy@89 | 81 } |
| paddy@89 | 82 |
| paddy@70 | 83 type sessionStore interface { |
| paddy@70 | 84 createSession(session Session) error |
| paddy@70 | 85 getSession(id string) (Session, error) |
| paddy@70 | 86 removeSession(id string) error |
| paddy@70 | 87 listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) |
| paddy@70 | 88 } |
| paddy@77 | 89 |
| paddy@77 | 90 func (m *memstore) createSession(session Session) error { |
| paddy@77 | 91 m.sessionLock.Lock() |
| paddy@77 | 92 defer m.sessionLock.Unlock() |
| paddy@77 | 93 if _, ok := m.sessions[session.ID]; ok { |
| paddy@77 | 94 return ErrSessionAlreadyExists |
| paddy@77 | 95 } |
| paddy@77 | 96 m.sessions[session.ID] = session |
| paddy@77 | 97 return nil |
| paddy@77 | 98 } |
| paddy@77 | 99 |
| paddy@77 | 100 func (m *memstore) getSession(id string) (Session, error) { |
| paddy@77 | 101 m.sessionLock.RLock() |
| paddy@77 | 102 defer m.sessionLock.RUnlock() |
| paddy@77 | 103 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 104 return Session{}, ErrSessionNotFound |
| paddy@77 | 105 } |
| paddy@77 | 106 return m.sessions[id], nil |
| paddy@77 | 107 } |
| paddy@77 | 108 |
| paddy@77 | 109 func (m *memstore) removeSession(id string) error { |
| paddy@77 | 110 m.sessionLock.Lock() |
| paddy@77 | 111 defer m.sessionLock.Unlock() |
| paddy@77 | 112 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 113 return ErrSessionNotFound |
| paddy@77 | 114 } |
| paddy@77 | 115 delete(m.sessions, id) |
| paddy@77 | 116 return nil |
| paddy@77 | 117 } |
| paddy@77 | 118 |
| paddy@77 | 119 func (m *memstore) listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) { |
| paddy@77 | 120 m.sessionLock.RLock() |
| paddy@77 | 121 defer m.sessionLock.RUnlock() |
| paddy@77 | 122 res := []Session{} |
| paddy@77 | 123 for _, session := range m.sessions { |
| paddy@77 | 124 if int64(len(res)) >= num { |
| paddy@77 | 125 break |
| paddy@77 | 126 } |
| paddy@77 | 127 if profile != nil && !profile.Equal(session.ProfileID) { |
| paddy@77 | 128 continue |
| paddy@77 | 129 } |
| paddy@77 | 130 if !before.IsZero() && session.Created.After(before) { |
| paddy@77 | 131 continue |
| paddy@77 | 132 } |
| paddy@77 | 133 res = append(res, session) |
| paddy@77 | 134 } |
| paddy@89 | 135 sorted := sortedSessions(res) |
| paddy@89 | 136 sort.Sort(sorted) |
| paddy@89 | 137 res = []Session(sorted) |
| paddy@77 | 138 return res, nil |
| paddy@77 | 139 } |
| paddy@98 | 140 |
| paddy@98 | 141 // RegisterSessionHandlers adds handlers to the passed router to handle the session endpoints, like login and logout. |
| paddy@98 | 142 func RegisterSessionHandlers(r *mux.Router, context Context) { |
| paddy@98 | 143 r.Handle("/login", wrap(context, CreateSessionHandler)) |
| paddy@128 | 144 // BUG(paddy): We need to implement a handler for listing sessions active on a profile. |
| paddy@128 | 145 // BUG(paddy): We need to implement a handler for terminating sessions. |
| paddy@98 | 146 } |
| paddy@98 | 147 |
| paddy@98 | 148 func checkCookie(r *http.Request, context Context) (Session, error) { |
| paddy@98 | 149 cookie, err := r.Cookie(authCookieName) |
| paddy@98 | 150 if err == http.ErrNoCookie { |
| paddy@98 | 151 return Session{}, ErrNoSession |
| paddy@98 | 152 } else if err != nil { |
| paddy@98 | 153 log.Println(err) |
| paddy@98 | 154 return Session{}, err |
| paddy@98 | 155 } |
| paddy@98 | 156 sess, err := context.GetSession(cookie.Value) |
| paddy@98 | 157 if err == ErrSessionNotFound { |
| paddy@98 | 158 return Session{}, ErrInvalidSession |
| paddy@98 | 159 } else if err != nil { |
| paddy@98 | 160 return Session{}, err |
| paddy@98 | 161 } |
| paddy@98 | 162 if !sess.Active { |
| paddy@98 | 163 return Session{}, ErrInvalidSession |
| paddy@98 | 164 } |
| paddy@98 | 165 return sess, nil |
| paddy@98 | 166 } |
| paddy@98 | 167 |
| paddy@98 | 168 func buildLoginRedirect(r *http.Request, context Context) string { |
| paddy@98 | 169 if context.loginURI == nil { |
| paddy@98 | 170 return "" |
| paddy@98 | 171 } |
| paddy@98 | 172 uri := *context.loginURI |
| paddy@98 | 173 q := uri.Query() |
| paddy@98 | 174 q.Set("from", r.URL.String()) |
| paddy@98 | 175 uri.RawQuery = q.Encode() |
| paddy@98 | 176 return uri.String() |
| paddy@98 | 177 } |
| paddy@98 | 178 |
| paddy@98 | 179 func pbkdf2sha256check(profile Profile, passphrase string) (bool, error) { |
| paddy@98 | 180 realPass, err := hex.DecodeString(profile.Passphrase) |
| paddy@98 | 181 if err != nil { |
| paddy@98 | 182 return false, err |
| paddy@98 | 183 } |
| paddy@103 | 184 realSalt, err := hex.DecodeString(profile.Salt) |
| paddy@103 | 185 if err != nil { |
| paddy@103 | 186 return false, err |
| paddy@103 | 187 } |
| paddy@103 | 188 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(realSalt)) |
| paddy@98 | 189 if !pass.Compare(candidate, realPass) { |
| paddy@98 | 190 return false, ErrIncorrectAuth |
| paddy@98 | 191 } |
| paddy@98 | 192 return true, nil |
| paddy@98 | 193 } |
| paddy@98 | 194 |
| paddy@103 | 195 func pbkdf2sha256create(passphrase string, iters int) (result, salt string, err error) { |
| paddy@103 | 196 passBytes, saltBytes, err := pass.Create(sha256.New, iters, []byte(passphrase)) |
| paddy@103 | 197 if err != nil { |
| paddy@103 | 198 return "", "", err |
| paddy@103 | 199 } |
| paddy@103 | 200 result = hex.EncodeToString(passBytes) |
| paddy@103 | 201 salt = hex.EncodeToString(saltBytes) |
| paddy@103 | 202 return result, salt, err |
| paddy@98 | 203 } |
| paddy@98 | 204 |
| paddy@98 | 205 func pbkdf2sha256calc() (int, error) { |
| paddy@98 | 206 return pass.CalculateIterations(sha256.New) |
| paddy@98 | 207 } |
| paddy@98 | 208 |
| paddy@98 | 209 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@98 | 210 profile, err := context.GetProfileByLogin(user) |
| paddy@98 | 211 if err != nil { |
| paddy@98 | 212 if err == ErrProfileNotFound || err == ErrLoginNotFound { |
| paddy@98 | 213 return Profile{}, ErrIncorrectAuth |
| paddy@98 | 214 } |
| paddy@98 | 215 return Profile{}, err |
| paddy@98 | 216 } |
| paddy@98 | 217 if profile.Compromised { |
| paddy@98 | 218 return Profile{}, ErrProfileCompromised |
| paddy@98 | 219 } |
| paddy@98 | 220 if !profile.LockedUntil.IsZero() && profile.LockedUntil.After(time.Now()) { |
| paddy@98 | 221 return profile, ErrProfileLocked |
| paddy@98 | 222 } |
| paddy@98 | 223 scheme, ok := passphraseSchemes[profile.PassphraseScheme] |
| paddy@98 | 224 if !ok { |
| paddy@98 | 225 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@98 | 226 } |
| paddy@98 | 227 result, err := scheme.check(profile, passphrase) |
| paddy@98 | 228 if !result { |
| paddy@98 | 229 return Profile{}, err |
| paddy@98 | 230 } |
| paddy@98 | 231 return profile, nil |
| paddy@98 | 232 } |
| paddy@98 | 233 |
| paddy@98 | 234 // CreateSessionHandler allows the user to log into their account and create their session. |
| paddy@98 | 235 func CreateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@98 | 236 // BUG(paddy): Creating a session needs CSRF protection, right? This whole thing should get a security audit |
| paddy@98 | 237 errors := []error{} |
| paddy@98 | 238 if r.Method == "POST" { |
| paddy@98 | 239 profile, err := authenticate(r.PostFormValue("login"), r.PostFormValue("passphrase"), context) |
| paddy@98 | 240 if err == nil { |
| paddy@98 | 241 ip := r.Header.Get("X-Forwarded-For") |
| paddy@98 | 242 if ip == "" { |
| paddy@98 | 243 ip = r.RemoteAddr |
| paddy@98 | 244 } |
| paddy@98 | 245 session := Session{ |
| paddy@98 | 246 ID: uuid.NewID().String(), |
| paddy@98 | 247 IP: ip, |
| paddy@98 | 248 UserAgent: r.UserAgent(), |
| paddy@98 | 249 ProfileID: profile.ID, |
| paddy@98 | 250 Login: r.PostFormValue("login"), |
| paddy@98 | 251 Created: time.Now(), |
| paddy@98 | 252 Active: true, |
| paddy@98 | 253 } |
| paddy@98 | 254 err = context.CreateSession(session) |
| paddy@98 | 255 if err != nil { |
| paddy@98 | 256 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 257 w.Write([]byte(err.Error())) |
| paddy@98 | 258 return |
| paddy@98 | 259 } |
| paddy@128 | 260 // BUG(paddy): We really need to do a security audit on our cookie. |
| paddy@98 | 261 cookie := http.Cookie{ |
| paddy@98 | 262 Name: authCookieName, |
| paddy@98 | 263 Value: session.ID, |
| paddy@98 | 264 Expires: time.Now().Add(24 * 7 * time.Hour), |
| paddy@98 | 265 HttpOnly: true, |
| paddy@98 | 266 } |
| paddy@98 | 267 http.SetCookie(w, &cookie) |
| paddy@98 | 268 redirectTo := r.URL.Query().Get("from") |
| paddy@98 | 269 if redirectTo == "" { |
| paddy@98 | 270 redirectTo = "/" |
| paddy@98 | 271 } |
| paddy@98 | 272 http.Redirect(w, r, redirectTo, http.StatusFound) |
| paddy@98 | 273 return |
| paddy@98 | 274 } else if err != ErrIncorrectAuth && err != ErrProfileCompromised && err != ErrProfileLocked { |
| paddy@98 | 275 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 276 w.Write([]byte(err.Error())) |
| paddy@98 | 277 return |
| paddy@98 | 278 } else { |
| paddy@98 | 279 errors = append(errors, err) |
| paddy@98 | 280 } |
| paddy@98 | 281 } |
| paddy@98 | 282 context.Render(w, loginTemplateName, map[string]interface{}{ |
| paddy@98 | 283 "errors": errors, |
| paddy@98 | 284 }) |
| paddy@98 | 285 } |
| paddy@119 | 286 |
| paddy@119 | 287 func credentialsValidate(w http.ResponseWriter, r *http.Request, context Context) (scope string, profileID uuid.ID, valid bool) { |
| paddy@119 | 288 enc := json.NewEncoder(w) |
| paddy@119 | 289 username := r.PostFormValue("username") |
| paddy@119 | 290 password := r.PostFormValue("password") |
| paddy@119 | 291 scope = r.PostFormValue("scope") |
| paddy@119 | 292 profile, err := authenticate(username, password, context) |
| paddy@119 | 293 if err != nil { |
| paddy@119 | 294 if err == ErrIncorrectAuth || err == ErrProfileCompromised || err == ErrProfileLocked { |
| paddy@119 | 295 w.WriteHeader(http.StatusBadRequest) |
| paddy@119 | 296 renderJSONError(enc, "invalid_grant") |
| paddy@119 | 297 return |
| paddy@119 | 298 } |
| paddy@119 | 299 w.WriteHeader(http.StatusInternalServerError) |
| paddy@119 | 300 w.Write([]byte(err.Error())) |
| paddy@119 | 301 return |
| paddy@119 | 302 } |
| paddy@119 | 303 profileID = profile.ID |
| paddy@119 | 304 valid = true |
| paddy@119 | 305 return |
| paddy@119 | 306 } |
| paddy@124 | 307 |
| paddy@124 | 308 func credentialsAuditString(r *http.Request) string { |
| paddy@124 | 309 return "credentials" |
| paddy@124 | 310 } |