auth
auth/session.go
Make all tests that deal with the store interfaces go through the Context. This is mainly important so that pre- and post- save/retrieval/deletion/whatever transforms can be done without doing them in every single implementation of the store. Change the Endpoint URI property to be a string, not a *url.URL. This makes testing easier, JSON responses cleaner, and is all around just a better strategy. Just because we turn it into a URL every now and then doesn't mean that's how we need to store it. Add JSON tags to the Client type and Endpoint type. Create normalizeURI and normalizeURIString methods to... well, normalize the Endpoint URIs. This makes it so that we can compare them, and forgive some arbitrary user behaviour (like slashes, etc.) Add a NormalizedURI property to the Endpoint type. This is where we store the NormalizedURI, which is what we'll be using when we want to check if an endpoint is valid or not. For the sake of tests and predictability, however, we always want to redirect to the URI, not the NormalizedURI. Add checks to the Client creation API endpoint to give better errors. Now leaving out the Type won't be considered an invalid type, it will be considered a missing parameter. An empty name will be reported as a missing parameter, a name with too few characters will be reported as an insufficient name, and a name with too many characters will be reported as an overflow name. We gather as many of these errors as apply before returning. Check if an Endpoint URI is absolute before adding it as an endpoint, or return an invalid value error if it is not. Always return the errors array when creating a client. We could succeed in creating one or more things and still have errors. We should return anything that's created _as well as_ any errors encountered. Add unit testing for our CreateClientHandler. Fix our oauth2 tests so that if there's an error in the body, it's in the test logs. This should help debugging significantly. Fix our oauth2 tests so that the Profile only requires 1 iteration for its password hashing. This means each time we want to validate a session, it doesn't add a full second to our test runs. This is a big speed improvement for our tests. Add test helper methods for comparing API errors, API responses, and filling in server-generated information in a response that it's impossible to have an expectation around (e.g., IDs) so that we can use our comparison helpers to check if a response is as we expect it. Fix a typo in our Context helpers that was reporting no sessionStore being set _only_ when a sessionStore was set. So yes, the opposite of what we wanted. Oops. This was discovered by passing all our tests through the context. methods instead of operating on the stores themselves.
| paddy@70 | 1 package auth |
| paddy@70 | 2 |
| paddy@70 | 3 import ( |
| paddy@98 | 4 "crypto/sha256" |
| paddy@98 | 5 "encoding/hex" |
| paddy@70 | 6 "errors" |
| paddy@98 | 7 "log" |
| paddy@98 | 8 "net/http" |
| paddy@89 | 9 "sort" |
| paddy@70 | 10 "time" |
| paddy@70 | 11 |
| paddy@107 | 12 "code.secondbit.org/pass.hg" |
| paddy@107 | 13 "code.secondbit.org/uuid.hg" |
| paddy@98 | 14 "github.com/gorilla/mux" |
| paddy@98 | 15 ) |
| paddy@98 | 16 |
| paddy@98 | 17 const ( |
| paddy@98 | 18 loginTemplateName = "login" |
| paddy@70 | 19 ) |
| paddy@70 | 20 |
| paddy@70 | 21 var ( |
| paddy@70 | 22 // ErrNoSessionStore is returned when a Context tries to act on a sessionStore without setting on first. |
| paddy@70 | 23 ErrNoSessionStore = errors.New("no sessionStore was specified for the Context") |
| paddy@70 | 24 // ErrSessionNotFound is returned when a Session is requested but not found in the sessionStore. |
| paddy@70 | 25 ErrSessionNotFound = errors.New("session not found in sessionStore") |
| paddy@70 | 26 // ErrInvalidSession is returned when a Session is specified but is not valid. |
| paddy@70 | 27 ErrInvalidSession = errors.New("session is not valid") |
| paddy@77 | 28 // ErrSessionAlreadyExists is returned when a sessionStore tries to store a Session with an ID that already exists in the sessionStore. |
| paddy@77 | 29 ErrSessionAlreadyExists = errors.New("session already exists") |
| paddy@98 | 30 |
| paddy@98 | 31 passphraseSchemes = map[int]passphraseScheme{ |
| paddy@98 | 32 1: { |
| paddy@98 | 33 check: pbkdf2sha256check, |
| paddy@98 | 34 create: pbkdf2sha256create, |
| paddy@98 | 35 calculateIterations: pbkdf2sha256calc, |
| paddy@98 | 36 }, |
| paddy@98 | 37 } |
| paddy@70 | 38 ) |
| paddy@70 | 39 |
| paddy@98 | 40 type passphraseScheme struct { |
| paddy@98 | 41 check func(profile Profile, passphrase string) (bool, error) |
| paddy@103 | 42 create func(passphrase string, iterations int) (result, salt string, err error) |
| paddy@98 | 43 calculateIterations func() (int, error) |
| paddy@98 | 44 } |
| paddy@98 | 45 |
| paddy@70 | 46 // Session represents a user's authenticated session, associating it with a profile |
| paddy@70 | 47 // and some audit data. |
| paddy@70 | 48 type Session struct { |
| paddy@70 | 49 ID string |
| paddy@70 | 50 IP string |
| paddy@70 | 51 UserAgent string |
| paddy@70 | 52 ProfileID uuid.ID |
| paddy@98 | 53 Login string |
| paddy@70 | 54 Created time.Time |
| paddy@70 | 55 Active bool |
| paddy@70 | 56 } |
| paddy@70 | 57 |
| paddy@89 | 58 type sortedSessions []Session |
| paddy@89 | 59 |
| paddy@89 | 60 func (s sortedSessions) Len() int { |
| paddy@89 | 61 return len(s) |
| paddy@89 | 62 } |
| paddy@89 | 63 |
| paddy@89 | 64 func (s sortedSessions) Less(i, j int) bool { |
| paddy@89 | 65 return s[i].Created.After(s[j].Created) |
| paddy@89 | 66 } |
| paddy@89 | 67 |
| paddy@89 | 68 func (s sortedSessions) Swap(i, j int) { |
| paddy@89 | 69 s[i], s[j] = s[j], s[i] |
| paddy@89 | 70 } |
| paddy@89 | 71 |
| paddy@70 | 72 type sessionStore interface { |
| paddy@70 | 73 createSession(session Session) error |
| paddy@70 | 74 getSession(id string) (Session, error) |
| paddy@70 | 75 removeSession(id string) error |
| paddy@70 | 76 listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) |
| paddy@70 | 77 } |
| paddy@77 | 78 |
| paddy@77 | 79 func (m *memstore) createSession(session Session) error { |
| paddy@77 | 80 m.sessionLock.Lock() |
| paddy@77 | 81 defer m.sessionLock.Unlock() |
| paddy@77 | 82 if _, ok := m.sessions[session.ID]; ok { |
| paddy@77 | 83 return ErrSessionAlreadyExists |
| paddy@77 | 84 } |
| paddy@77 | 85 m.sessions[session.ID] = session |
| paddy@77 | 86 return nil |
| paddy@77 | 87 } |
| paddy@77 | 88 |
| paddy@77 | 89 func (m *memstore) getSession(id string) (Session, error) { |
| paddy@77 | 90 m.sessionLock.RLock() |
| paddy@77 | 91 defer m.sessionLock.RUnlock() |
| paddy@77 | 92 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 93 return Session{}, ErrSessionNotFound |
| paddy@77 | 94 } |
| paddy@77 | 95 return m.sessions[id], nil |
| paddy@77 | 96 } |
| paddy@77 | 97 |
| paddy@77 | 98 func (m *memstore) removeSession(id string) error { |
| paddy@77 | 99 m.sessionLock.Lock() |
| paddy@77 | 100 defer m.sessionLock.Unlock() |
| paddy@77 | 101 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 102 return ErrSessionNotFound |
| paddy@77 | 103 } |
| paddy@77 | 104 delete(m.sessions, id) |
| paddy@77 | 105 return nil |
| paddy@77 | 106 } |
| paddy@77 | 107 |
| paddy@77 | 108 func (m *memstore) listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) { |
| paddy@77 | 109 m.sessionLock.RLock() |
| paddy@77 | 110 defer m.sessionLock.RUnlock() |
| paddy@77 | 111 res := []Session{} |
| paddy@77 | 112 for _, session := range m.sessions { |
| paddy@77 | 113 if int64(len(res)) >= num { |
| paddy@77 | 114 break |
| paddy@77 | 115 } |
| paddy@77 | 116 if profile != nil && !profile.Equal(session.ProfileID) { |
| paddy@77 | 117 continue |
| paddy@77 | 118 } |
| paddy@77 | 119 if !before.IsZero() && session.Created.After(before) { |
| paddy@77 | 120 continue |
| paddy@77 | 121 } |
| paddy@77 | 122 res = append(res, session) |
| paddy@77 | 123 } |
| paddy@89 | 124 sorted := sortedSessions(res) |
| paddy@89 | 125 sort.Sort(sorted) |
| paddy@89 | 126 res = []Session(sorted) |
| paddy@77 | 127 return res, nil |
| paddy@77 | 128 } |
| paddy@98 | 129 |
| paddy@98 | 130 // RegisterSessionHandlers adds handlers to the passed router to handle the session endpoints, like login and logout. |
| paddy@98 | 131 func RegisterSessionHandlers(r *mux.Router, context Context) { |
| paddy@98 | 132 r.Handle("/login", wrap(context, CreateSessionHandler)) |
| paddy@98 | 133 } |
| paddy@98 | 134 |
| paddy@98 | 135 func checkCookie(r *http.Request, context Context) (Session, error) { |
| paddy@98 | 136 cookie, err := r.Cookie(authCookieName) |
| paddy@98 | 137 if err == http.ErrNoCookie { |
| paddy@98 | 138 return Session{}, ErrNoSession |
| paddy@98 | 139 } else if err != nil { |
| paddy@98 | 140 log.Println(err) |
| paddy@98 | 141 return Session{}, err |
| paddy@98 | 142 } |
| paddy@98 | 143 sess, err := context.GetSession(cookie.Value) |
| paddy@98 | 144 if err == ErrSessionNotFound { |
| paddy@98 | 145 return Session{}, ErrInvalidSession |
| paddy@98 | 146 } else if err != nil { |
| paddy@98 | 147 return Session{}, err |
| paddy@98 | 148 } |
| paddy@98 | 149 if !sess.Active { |
| paddy@98 | 150 return Session{}, ErrInvalidSession |
| paddy@98 | 151 } |
| paddy@98 | 152 return sess, nil |
| paddy@98 | 153 } |
| paddy@98 | 154 |
| paddy@98 | 155 func buildLoginRedirect(r *http.Request, context Context) string { |
| paddy@98 | 156 if context.loginURI == nil { |
| paddy@98 | 157 return "" |
| paddy@98 | 158 } |
| paddy@98 | 159 uri := *context.loginURI |
| paddy@98 | 160 q := uri.Query() |
| paddy@98 | 161 q.Set("from", r.URL.String()) |
| paddy@98 | 162 uri.RawQuery = q.Encode() |
| paddy@98 | 163 return uri.String() |
| paddy@98 | 164 } |
| paddy@98 | 165 |
| paddy@98 | 166 func pbkdf2sha256check(profile Profile, passphrase string) (bool, error) { |
| paddy@98 | 167 realPass, err := hex.DecodeString(profile.Passphrase) |
| paddy@98 | 168 if err != nil { |
| paddy@98 | 169 return false, err |
| paddy@98 | 170 } |
| paddy@103 | 171 realSalt, err := hex.DecodeString(profile.Salt) |
| paddy@103 | 172 if err != nil { |
| paddy@103 | 173 return false, err |
| paddy@103 | 174 } |
| paddy@103 | 175 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(realSalt)) |
| paddy@98 | 176 if !pass.Compare(candidate, realPass) { |
| paddy@98 | 177 return false, ErrIncorrectAuth |
| paddy@98 | 178 } |
| paddy@98 | 179 return true, nil |
| paddy@98 | 180 } |
| paddy@98 | 181 |
| paddy@103 | 182 func pbkdf2sha256create(passphrase string, iters int) (result, salt string, err error) { |
| paddy@103 | 183 passBytes, saltBytes, err := pass.Create(sha256.New, iters, []byte(passphrase)) |
| paddy@103 | 184 if err != nil { |
| paddy@103 | 185 return "", "", err |
| paddy@103 | 186 } |
| paddy@103 | 187 result = hex.EncodeToString(passBytes) |
| paddy@103 | 188 salt = hex.EncodeToString(saltBytes) |
| paddy@103 | 189 return result, salt, err |
| paddy@98 | 190 } |
| paddy@98 | 191 |
| paddy@98 | 192 func pbkdf2sha256calc() (int, error) { |
| paddy@98 | 193 return pass.CalculateIterations(sha256.New) |
| paddy@98 | 194 } |
| paddy@98 | 195 |
| paddy@98 | 196 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@98 | 197 profile, err := context.GetProfileByLogin(user) |
| paddy@98 | 198 if err != nil { |
| paddy@98 | 199 if err == ErrProfileNotFound || err == ErrLoginNotFound { |
| paddy@98 | 200 return Profile{}, ErrIncorrectAuth |
| paddy@98 | 201 } |
| paddy@98 | 202 return Profile{}, err |
| paddy@98 | 203 } |
| paddy@98 | 204 if profile.Compromised { |
| paddy@98 | 205 return Profile{}, ErrProfileCompromised |
| paddy@98 | 206 } |
| paddy@98 | 207 if !profile.LockedUntil.IsZero() && profile.LockedUntil.After(time.Now()) { |
| paddy@98 | 208 return profile, ErrProfileLocked |
| paddy@98 | 209 } |
| paddy@98 | 210 scheme, ok := passphraseSchemes[profile.PassphraseScheme] |
| paddy@98 | 211 if !ok { |
| paddy@98 | 212 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@98 | 213 } |
| paddy@98 | 214 result, err := scheme.check(profile, passphrase) |
| paddy@98 | 215 if !result { |
| paddy@98 | 216 return Profile{}, err |
| paddy@98 | 217 } |
| paddy@98 | 218 return profile, nil |
| paddy@98 | 219 } |
| paddy@98 | 220 |
| paddy@98 | 221 // CreateSessionHandler allows the user to log into their account and create their session. |
| paddy@98 | 222 func CreateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@98 | 223 // BUG(paddy): Creating a session needs CSRF protection, right? This whole thing should get a security audit |
| paddy@98 | 224 errors := []error{} |
| paddy@98 | 225 if r.Method == "POST" { |
| paddy@98 | 226 profile, err := authenticate(r.PostFormValue("login"), r.PostFormValue("passphrase"), context) |
| paddy@98 | 227 if err == nil { |
| paddy@98 | 228 ip := r.Header.Get("X-Forwarded-For") |
| paddy@98 | 229 if ip == "" { |
| paddy@98 | 230 ip = r.RemoteAddr |
| paddy@98 | 231 } |
| paddy@98 | 232 session := Session{ |
| paddy@98 | 233 ID: uuid.NewID().String(), |
| paddy@98 | 234 IP: ip, |
| paddy@98 | 235 UserAgent: r.UserAgent(), |
| paddy@98 | 236 ProfileID: profile.ID, |
| paddy@98 | 237 Login: r.PostFormValue("login"), |
| paddy@98 | 238 Created: time.Now(), |
| paddy@98 | 239 Active: true, |
| paddy@98 | 240 } |
| paddy@98 | 241 err = context.CreateSession(session) |
| paddy@98 | 242 if err != nil { |
| paddy@98 | 243 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 244 w.Write([]byte(err.Error())) |
| paddy@98 | 245 return |
| paddy@98 | 246 } |
| paddy@98 | 247 // BUG(paddy): really need to do a security audit on our cookie |
| paddy@98 | 248 cookie := http.Cookie{ |
| paddy@98 | 249 Name: authCookieName, |
| paddy@98 | 250 Value: session.ID, |
| paddy@98 | 251 Expires: time.Now().Add(24 * 7 * time.Hour), |
| paddy@98 | 252 HttpOnly: true, |
| paddy@98 | 253 } |
| paddy@98 | 254 http.SetCookie(w, &cookie) |
| paddy@98 | 255 redirectTo := r.URL.Query().Get("from") |
| paddy@98 | 256 if redirectTo == "" { |
| paddy@98 | 257 redirectTo = "/" |
| paddy@98 | 258 } |
| paddy@98 | 259 http.Redirect(w, r, redirectTo, http.StatusFound) |
| paddy@98 | 260 return |
| paddy@98 | 261 } else if err != ErrIncorrectAuth && err != ErrProfileCompromised && err != ErrProfileLocked { |
| paddy@98 | 262 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 263 w.Write([]byte(err.Error())) |
| paddy@98 | 264 return |
| paddy@98 | 265 } else { |
| paddy@98 | 266 errors = append(errors, err) |
| paddy@98 | 267 } |
| paddy@98 | 268 } |
| paddy@98 | 269 context.Render(w, loginTemplateName, map[string]interface{}{ |
| paddy@98 | 270 "errors": errors, |
| paddy@98 | 271 }) |
| paddy@98 | 272 } |