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