auth
auth/session.go
Remove concept of usernames. We really have no reason to use usernames, and they're complicating things more than they need to. We're going to keep logins the same, because we want to be able to support OAuth2/OpenID/whatever logins in the future, and keeping a type associated with those logins is probably for the best.
| 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@70 | 94 removeSession(id string) error |
| paddy@70 | 95 listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) |
| paddy@70 | 96 } |
| paddy@77 | 97 |
| paddy@77 | 98 func (m *memstore) createSession(session Session) error { |
| paddy@77 | 99 m.sessionLock.Lock() |
| paddy@77 | 100 defer m.sessionLock.Unlock() |
| paddy@77 | 101 if _, ok := m.sessions[session.ID]; ok { |
| paddy@77 | 102 return ErrSessionAlreadyExists |
| paddy@77 | 103 } |
| paddy@77 | 104 m.sessions[session.ID] = session |
| paddy@77 | 105 return nil |
| paddy@77 | 106 } |
| paddy@77 | 107 |
| paddy@77 | 108 func (m *memstore) getSession(id string) (Session, error) { |
| paddy@77 | 109 m.sessionLock.RLock() |
| paddy@77 | 110 defer m.sessionLock.RUnlock() |
| paddy@77 | 111 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 112 return Session{}, ErrSessionNotFound |
| paddy@77 | 113 } |
| paddy@77 | 114 return m.sessions[id], nil |
| paddy@77 | 115 } |
| paddy@77 | 116 |
| paddy@77 | 117 func (m *memstore) removeSession(id string) error { |
| paddy@77 | 118 m.sessionLock.Lock() |
| paddy@77 | 119 defer m.sessionLock.Unlock() |
| paddy@77 | 120 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 121 return ErrSessionNotFound |
| paddy@77 | 122 } |
| paddy@77 | 123 delete(m.sessions, id) |
| paddy@77 | 124 return nil |
| paddy@77 | 125 } |
| paddy@77 | 126 |
| paddy@77 | 127 func (m *memstore) listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) { |
| paddy@77 | 128 m.sessionLock.RLock() |
| paddy@77 | 129 defer m.sessionLock.RUnlock() |
| paddy@77 | 130 res := []Session{} |
| paddy@77 | 131 for _, session := range m.sessions { |
| paddy@77 | 132 if int64(len(res)) >= num { |
| paddy@77 | 133 break |
| paddy@77 | 134 } |
| paddy@77 | 135 if profile != nil && !profile.Equal(session.ProfileID) { |
| paddy@77 | 136 continue |
| paddy@77 | 137 } |
| paddy@77 | 138 if !before.IsZero() && session.Created.After(before) { |
| paddy@77 | 139 continue |
| paddy@77 | 140 } |
| paddy@77 | 141 res = append(res, session) |
| paddy@77 | 142 } |
| paddy@89 | 143 sorted := sortedSessions(res) |
| paddy@89 | 144 sort.Sort(sorted) |
| paddy@89 | 145 res = []Session(sorted) |
| paddy@77 | 146 return res, nil |
| paddy@77 | 147 } |
| paddy@98 | 148 |
| paddy@98 | 149 // RegisterSessionHandlers adds handlers to the passed router to handle the session endpoints, like login and logout. |
| paddy@98 | 150 func RegisterSessionHandlers(r *mux.Router, context Context) { |
| paddy@98 | 151 r.Handle("/login", wrap(context, CreateSessionHandler)) |
| paddy@128 | 152 // BUG(paddy): We need to implement a handler for listing sessions active on a profile. |
| paddy@128 | 153 // BUG(paddy): We need to implement a handler for terminating sessions. |
| paddy@98 | 154 } |
| paddy@98 | 155 |
| paddy@132 | 156 func checkCSRF(r *http.Request, s Session) error { |
| paddy@132 | 157 if r.PostFormValue("csrftoken") != s.CSRFToken { |
| paddy@132 | 158 return ErrCSRFAttempt |
| paddy@132 | 159 } |
| paddy@132 | 160 return nil |
| paddy@132 | 161 } |
| paddy@132 | 162 |
| paddy@98 | 163 func checkCookie(r *http.Request, context Context) (Session, error) { |
| paddy@98 | 164 cookie, err := r.Cookie(authCookieName) |
| paddy@98 | 165 if err == http.ErrNoCookie { |
| paddy@98 | 166 return Session{}, ErrNoSession |
| paddy@98 | 167 } else if err != nil { |
| paddy@98 | 168 log.Println(err) |
| paddy@98 | 169 return Session{}, err |
| paddy@98 | 170 } |
| paddy@98 | 171 sess, err := context.GetSession(cookie.Value) |
| paddy@98 | 172 if err == ErrSessionNotFound { |
| paddy@98 | 173 return Session{}, ErrInvalidSession |
| paddy@98 | 174 } else if err != nil { |
| paddy@98 | 175 return Session{}, err |
| paddy@98 | 176 } |
| paddy@98 | 177 if !sess.Active { |
| paddy@98 | 178 return Session{}, ErrInvalidSession |
| paddy@98 | 179 } |
| paddy@132 | 180 if time.Now().After(sess.Expires) { |
| paddy@132 | 181 return Session{}, ErrInvalidSession |
| paddy@132 | 182 } |
| paddy@98 | 183 return sess, nil |
| paddy@98 | 184 } |
| paddy@98 | 185 |
| paddy@98 | 186 func buildLoginRedirect(r *http.Request, context Context) string { |
| paddy@98 | 187 if context.loginURI == nil { |
| paddy@98 | 188 return "" |
| paddy@98 | 189 } |
| paddy@98 | 190 uri := *context.loginURI |
| paddy@98 | 191 q := uri.Query() |
| paddy@98 | 192 q.Set("from", r.URL.String()) |
| paddy@98 | 193 uri.RawQuery = q.Encode() |
| paddy@98 | 194 return uri.String() |
| paddy@98 | 195 } |
| paddy@98 | 196 |
| paddy@98 | 197 func pbkdf2sha256check(profile Profile, passphrase string) (bool, error) { |
| paddy@98 | 198 realPass, err := hex.DecodeString(profile.Passphrase) |
| paddy@98 | 199 if err != nil { |
| paddy@98 | 200 return false, err |
| paddy@98 | 201 } |
| paddy@103 | 202 realSalt, err := hex.DecodeString(profile.Salt) |
| paddy@103 | 203 if err != nil { |
| paddy@103 | 204 return false, err |
| paddy@103 | 205 } |
| paddy@103 | 206 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(realSalt)) |
| paddy@98 | 207 if !pass.Compare(candidate, realPass) { |
| paddy@98 | 208 return false, ErrIncorrectAuth |
| paddy@98 | 209 } |
| paddy@98 | 210 return true, nil |
| paddy@98 | 211 } |
| paddy@98 | 212 |
| paddy@103 | 213 func pbkdf2sha256create(passphrase string, iters int) (result, salt string, err error) { |
| paddy@103 | 214 passBytes, saltBytes, err := pass.Create(sha256.New, iters, []byte(passphrase)) |
| paddy@103 | 215 if err != nil { |
| paddy@103 | 216 return "", "", err |
| paddy@103 | 217 } |
| paddy@103 | 218 result = hex.EncodeToString(passBytes) |
| paddy@103 | 219 salt = hex.EncodeToString(saltBytes) |
| paddy@103 | 220 return result, salt, err |
| paddy@98 | 221 } |
| paddy@98 | 222 |
| paddy@98 | 223 func pbkdf2sha256calc() (int, error) { |
| paddy@98 | 224 return pass.CalculateIterations(sha256.New) |
| paddy@98 | 225 } |
| paddy@98 | 226 |
| paddy@139 | 227 func isAuthError(err error) bool { |
| paddy@139 | 228 return err == ErrIncorrectAuth || err == ErrProfileCompromised || err == ErrProfileLocked || err == ErrInvalidPassphraseScheme |
| paddy@139 | 229 } |
| paddy@139 | 230 |
| paddy@98 | 231 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@98 | 232 profile, err := context.GetProfileByLogin(user) |
| paddy@98 | 233 if err != nil { |
| paddy@98 | 234 if err == ErrProfileNotFound || err == ErrLoginNotFound { |
| paddy@98 | 235 return Profile{}, ErrIncorrectAuth |
| paddy@98 | 236 } |
| paddy@98 | 237 return Profile{}, err |
| paddy@98 | 238 } |
| paddy@98 | 239 if profile.Compromised { |
| paddy@98 | 240 return Profile{}, ErrProfileCompromised |
| paddy@98 | 241 } |
| paddy@98 | 242 if !profile.LockedUntil.IsZero() && profile.LockedUntil.After(time.Now()) { |
| paddy@98 | 243 return profile, ErrProfileLocked |
| paddy@98 | 244 } |
| paddy@98 | 245 scheme, ok := passphraseSchemes[profile.PassphraseScheme] |
| paddy@98 | 246 if !ok { |
| paddy@98 | 247 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@98 | 248 } |
| paddy@98 | 249 result, err := scheme.check(profile, passphrase) |
| paddy@98 | 250 if !result { |
| paddy@98 | 251 return Profile{}, err |
| paddy@98 | 252 } |
| paddy@98 | 253 return profile, nil |
| paddy@98 | 254 } |
| paddy@98 | 255 |
| paddy@98 | 256 // CreateSessionHandler allows the user to log into their account and create their session. |
| paddy@98 | 257 func CreateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@98 | 258 errors := []error{} |
| paddy@98 | 259 if r.Method == "POST" { |
| paddy@98 | 260 profile, err := authenticate(r.PostFormValue("login"), r.PostFormValue("passphrase"), context) |
| paddy@98 | 261 if err == nil { |
| paddy@98 | 262 ip := r.Header.Get("X-Forwarded-For") |
| paddy@98 | 263 if ip == "" { |
| paddy@98 | 264 ip = r.RemoteAddr |
| paddy@98 | 265 } |
| paddy@132 | 266 sessionID := make([]byte, 32) |
| paddy@132 | 267 csrfToken := make([]byte, 32) |
| paddy@132 | 268 _, err = rand.Read(sessionID) |
| paddy@132 | 269 if err != nil { |
| paddy@132 | 270 log.Println("Error reading CSPRNG for session ID:", err) |
| paddy@132 | 271 w.WriteHeader(http.StatusInternalServerError) |
| paddy@132 | 272 w.Write([]byte("Internal error")) |
| paddy@132 | 273 return |
| paddy@132 | 274 } |
| paddy@132 | 275 _, err = rand.Read(csrfToken) |
| paddy@132 | 276 if err != nil { |
| paddy@132 | 277 log.Println("Error reading CSPRNG for CSRF token:", err) |
| paddy@132 | 278 w.WriteHeader(http.StatusInternalServerError) |
| paddy@132 | 279 w.Write([]byte("internal error")) |
| paddy@132 | 280 return |
| paddy@132 | 281 } |
| paddy@98 | 282 session := Session{ |
| paddy@132 | 283 ID: base64.StdEncoding.EncodeToString(sessionID), |
| paddy@98 | 284 IP: ip, |
| paddy@98 | 285 UserAgent: r.UserAgent(), |
| paddy@98 | 286 ProfileID: profile.ID, |
| paddy@98 | 287 Login: r.PostFormValue("login"), |
| paddy@98 | 288 Created: time.Now(), |
| paddy@132 | 289 Expires: time.Now().Add(time.Hour), |
| paddy@98 | 290 Active: true, |
| paddy@132 | 291 CSRFToken: base64.StdEncoding.EncodeToString(csrfToken), |
| paddy@98 | 292 } |
| paddy@98 | 293 err = context.CreateSession(session) |
| paddy@98 | 294 if err != nil { |
| paddy@98 | 295 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 296 w.Write([]byte(err.Error())) |
| paddy@98 | 297 return |
| paddy@98 | 298 } |
| paddy@132 | 299 // BUG(paddy): We really need to do a security audit on our cookies. |
| paddy@98 | 300 cookie := http.Cookie{ |
| paddy@98 | 301 Name: authCookieName, |
| paddy@98 | 302 Value: session.ID, |
| paddy@132 | 303 Expires: session.Expires, |
| paddy@98 | 304 HttpOnly: true, |
| paddy@132 | 305 Secure: context.config.secureCookie, |
| paddy@98 | 306 } |
| paddy@98 | 307 http.SetCookie(w, &cookie) |
| paddy@98 | 308 redirectTo := r.URL.Query().Get("from") |
| paddy@98 | 309 if redirectTo == "" { |
| paddy@98 | 310 redirectTo = "/" |
| paddy@98 | 311 } |
| paddy@98 | 312 http.Redirect(w, r, redirectTo, http.StatusFound) |
| paddy@98 | 313 return |
| paddy@139 | 314 } else if !isAuthError(err) { |
| paddy@98 | 315 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 316 w.Write([]byte(err.Error())) |
| paddy@98 | 317 return |
| paddy@98 | 318 } else { |
| paddy@98 | 319 errors = append(errors, err) |
| paddy@98 | 320 } |
| paddy@98 | 321 } |
| paddy@98 | 322 context.Render(w, loginTemplateName, map[string]interface{}{ |
| paddy@98 | 323 "errors": errors, |
| paddy@98 | 324 }) |
| paddy@98 | 325 } |
| paddy@119 | 326 |
| paddy@135 | 327 func credentialsValidate(w http.ResponseWriter, r *http.Request, context Context) (scopes []string, profileID uuid.ID, valid bool) { |
| paddy@119 | 328 enc := json.NewEncoder(w) |
| paddy@119 | 329 username := r.PostFormValue("username") |
| paddy@119 | 330 password := r.PostFormValue("password") |
| paddy@135 | 331 scopes = strings.Split(r.PostFormValue("scope"), " ") |
| paddy@119 | 332 profile, err := authenticate(username, password, context) |
| paddy@119 | 333 if err != nil { |
| paddy@139 | 334 if isAuthError(err) { |
| paddy@119 | 335 w.WriteHeader(http.StatusBadRequest) |
| paddy@119 | 336 renderJSONError(enc, "invalid_grant") |
| paddy@119 | 337 return |
| paddy@119 | 338 } |
| paddy@119 | 339 w.WriteHeader(http.StatusInternalServerError) |
| paddy@119 | 340 w.Write([]byte(err.Error())) |
| paddy@119 | 341 return |
| paddy@119 | 342 } |
| paddy@119 | 343 profileID = profile.ID |
| paddy@119 | 344 valid = true |
| paddy@119 | 345 return |
| paddy@119 | 346 } |
| paddy@124 | 347 |
| paddy@124 | 348 func credentialsAuditString(r *http.Request) string { |
| paddy@124 | 349 return "credentials" |
| paddy@124 | 350 } |