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.
16 "code.secondbit.org/pass.hg"
17 "code.secondbit.org/uuid.hg"
18 "github.com/gorilla/mux"
22 authCookieName = "auth"
23 loginTemplateName = "login"
27 RegisterGrantType("password", GrantType{
28 Validate: credentialsValidate,
31 ReturnToken: RenderJSONToken,
32 AuditString: credentialsAuditString,
37 // ErrNoSessionStore is returned when a Context tries to act on a sessionStore without setting on first.
38 ErrNoSessionStore = errors.New("no sessionStore was specified for the Context")
39 // ErrSessionNotFound is returned when a Session is requested but not found in the sessionStore.
40 ErrSessionNotFound = errors.New("session not found in sessionStore")
41 // ErrInvalidSession is returned when a Session is specified but is not valid.
42 ErrInvalidSession = errors.New("session is not valid")
43 // ErrSessionAlreadyExists is returned when a sessionStore tries to store a Session with an ID that already exists in the sessionStore.
44 ErrSessionAlreadyExists = errors.New("session already exists")
45 // ErrCSRFAttempt is returned when a CSRF attempt is detected.
46 ErrCSRFAttempt = errors.New("CSRF attempt")
48 passphraseSchemes = map[int]passphraseScheme{
50 check: pbkdf2sha256check,
51 create: pbkdf2sha256create,
52 calculateIterations: pbkdf2sha256calc,
57 type passphraseScheme struct {
58 check func(profile Profile, passphrase string) (bool, error)
59 create func(passphrase string, iterations int) (result, salt string, err error)
60 calculateIterations func() (int, error)
63 // Session represents a user's authenticated session, associating it with a profile
64 // and some audit data.
77 type sortedSessions []Session
79 func (s sortedSessions) Len() int {
83 func (s sortedSessions) Less(i, j int) bool {
84 return s[i].Created.After(s[j].Created)
87 func (s sortedSessions) Swap(i, j int) {
88 s[i], s[j] = s[j], s[i]
91 type sessionStore interface {
92 createSession(session Session) error
93 getSession(id string) (Session, error)
94 terminateSession(id string) error
95 removeSession(id string) error
96 listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error)
99 func (m *memstore) createSession(session Session) error {
101 defer m.sessionLock.Unlock()
102 if _, ok := m.sessions[session.ID]; ok {
103 return ErrSessionAlreadyExists
105 m.sessions[session.ID] = session
109 func (m *memstore) getSession(id string) (Session, error) {
110 m.sessionLock.RLock()
111 defer m.sessionLock.RUnlock()
112 if _, ok := m.sessions[id]; !ok {
113 return Session{}, ErrSessionNotFound
115 return m.sessions[id], nil
118 func (m *memstore) terminateSession(id string) error {
119 m.sessionLock.RLock()
120 defer m.sessionLock.RUnlock()
121 sess, ok := m.sessions[id]
123 return ErrSessionNotFound
126 m.sessions[id] = sess
130 func (m *memstore) removeSession(id string) error {
132 defer m.sessionLock.Unlock()
133 if _, ok := m.sessions[id]; !ok {
134 return ErrSessionNotFound
136 delete(m.sessions, id)
140 func (m *memstore) listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) {
141 m.sessionLock.RLock()
142 defer m.sessionLock.RUnlock()
144 for _, session := range m.sessions {
145 if int64(len(res)) >= num {
148 if profile != nil && !profile.Equal(session.ProfileID) {
151 if !before.IsZero() && session.Created.After(before) {
154 res = append(res, session)
156 sorted := sortedSessions(res)
158 res = []Session(sorted)
162 // RegisterSessionHandlers adds handlers to the passed router to handle the session endpoints, like login and logout.
163 func RegisterSessionHandlers(r *mux.Router, context Context) {
164 r.Handle("/login", wrap(context, CreateSessionHandler))
165 // BUG(paddy): We need to implement a handler for listing sessions active on a profile.
166 r.Handle("/sessions/{id}", wrap(context, TerminateSessionHandler)).Methods("OPTIONS", "DELETE")
169 func checkCSRF(r *http.Request, s Session) error {
170 if r.PostFormValue("csrftoken") != s.CSRFToken {
171 return ErrCSRFAttempt
176 func checkCookie(r *http.Request, context Context) (Session, error) {
177 cookie, err := r.Cookie(authCookieName)
178 if err == http.ErrNoCookie {
179 return Session{}, ErrNoSession
180 } else if err != nil {
182 return Session{}, err
184 sess, err := context.GetSession(cookie.Value)
185 if err == ErrSessionNotFound {
186 return Session{}, ErrInvalidSession
187 } else if err != nil {
188 return Session{}, err
191 return Session{}, ErrInvalidSession
193 if time.Now().After(sess.Expires) {
194 return Session{}, ErrInvalidSession
199 func buildLoginRedirect(r *http.Request, context Context) string {
200 if context.loginURI == nil {
203 uri := *context.loginURI
205 q.Set("from", r.URL.String())
206 uri.RawQuery = q.Encode()
210 func pbkdf2sha256check(profile Profile, passphrase string) (bool, error) {
211 realPass, err := hex.DecodeString(profile.Passphrase)
215 realSalt, err := hex.DecodeString(profile.Salt)
219 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(realSalt))
220 if !pass.Compare(candidate, realPass) {
221 return false, ErrIncorrectAuth
226 func pbkdf2sha256create(passphrase string, iters int) (result, salt string, err error) {
227 passBytes, saltBytes, err := pass.Create(sha256.New, iters, []byte(passphrase))
231 result = hex.EncodeToString(passBytes)
232 salt = hex.EncodeToString(saltBytes)
233 return result, salt, err
236 func pbkdf2sha256calc() (int, error) {
237 return pass.CalculateIterations(sha256.New)
240 func isAuthError(err error) bool {
241 return err == ErrIncorrectAuth || err == ErrProfileCompromised || err == ErrProfileLocked || err == ErrInvalidPassphraseScheme
244 func authenticate(user, passphrase string, context Context) (Profile, error) {
245 profile, err := context.GetProfileByLogin(user)
247 if err == ErrProfileNotFound || err == ErrLoginNotFound {
248 return Profile{}, ErrIncorrectAuth
250 return Profile{}, err
252 if profile.Compromised {
253 return Profile{}, ErrProfileCompromised
255 if !profile.LockedUntil.IsZero() && profile.LockedUntil.After(time.Now()) {
256 return profile, ErrProfileLocked
258 scheme, ok := passphraseSchemes[profile.PassphraseScheme]
260 return Profile{}, ErrInvalidPassphraseScheme
262 result, err := scheme.check(profile, passphrase)
264 return Profile{}, err
269 // CreateSessionHandler allows the user to log into their account and create their session.
270 func CreateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) {
272 if r.Method == "POST" {
273 profile, err := authenticate(r.PostFormValue("login"), r.PostFormValue("passphrase"), context)
275 ip := r.Header.Get("X-Forwarded-For")
279 sessionID := make([]byte, 32)
280 csrfToken := make([]byte, 32)
281 _, err = rand.Read(sessionID)
283 log.Println("Error reading CSPRNG for session ID:", err)
284 w.WriteHeader(http.StatusInternalServerError)
285 w.Write([]byte("Internal error"))
288 _, err = rand.Read(csrfToken)
290 log.Println("Error reading CSPRNG for CSRF token:", err)
291 w.WriteHeader(http.StatusInternalServerError)
292 w.Write([]byte("internal error"))
296 ID: base64.URLEncoding.EncodeToString(sessionID),
298 UserAgent: r.UserAgent(),
299 ProfileID: profile.ID,
300 Login: r.PostFormValue("login"),
302 Expires: time.Now().Add(time.Hour),
304 CSRFToken: base64.URLEncoding.EncodeToString(csrfToken),
306 err = context.CreateSession(session)
308 w.WriteHeader(http.StatusInternalServerError)
309 w.Write([]byte(err.Error()))
312 // BUG(paddy): We really need to do a security audit on our cookies.
313 cookie := http.Cookie{
314 Name: authCookieName,
316 Expires: session.Expires,
318 Secure: context.config.secureCookie,
320 http.SetCookie(w, &cookie)
321 redirectTo := r.URL.Query().Get("from")
322 if redirectTo == "" {
325 http.Redirect(w, r, redirectTo, http.StatusFound)
327 } else if !isAuthError(err) {
328 w.WriteHeader(http.StatusInternalServerError)
329 w.Write([]byte(err.Error()))
332 errors = append(errors, err)
335 context.Render(w, loginTemplateName, map[string]interface{}{
340 // TerminateSessionHandler allows the user to end their session before it expires.
341 func TerminateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) {
342 var errors []requestError
344 if vars["id"] == "" {
345 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
346 encode(w, r, http.StatusBadRequest, response{Errors: errors})
350 un, pw, ok := r.BasicAuth()
352 errors = append(errors, requestError{Slug: requestErrAccessDenied})
353 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
356 profile, err := authenticate(un, pw, context)
358 if isAuthError(err) {
359 errors = append(errors, requestError{Slug: requestErrAccessDenied})
360 encode(w, r, http.StatusForbidden, response{Errors: errors})
363 errors = append(errors, requestError{Slug: requestErrActOfGod})
364 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
367 session, err := context.GetSession(id)
369 if err == ErrSessionNotFound {
370 errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
371 encode(w, r, http.StatusNotFound, response{Errors: errors})
374 errors = append(errors, requestError{Slug: requestErrActOfGod})
375 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
378 if !session.ProfileID.Equal(profile.ID) {
379 errors = append(errors, requestError{Slug: requestErrAccessDenied, Param: "id"})
380 encode(w, r, http.StatusForbidden, response{Errors: errors})
383 err = context.TerminateSession(id)
385 if err == ErrSessionNotFound {
386 errors = append(errors, requestError{Slug: requestErrNotFound, Param: "id"})
387 encode(w, r, http.StatusNotFound, response{Errors: errors})
390 errors = append(errors, requestError{Slug: requestErrActOfGod})
391 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
394 session.Active = false
395 encode(w, r, http.StatusOK, response{Sessions: []Session{session}, Errors: errors})
398 func credentialsValidate(w http.ResponseWriter, r *http.Request, context Context) (scopes []string, profileID uuid.ID, valid bool) {
399 enc := json.NewEncoder(w)
400 username := r.PostFormValue("username")
401 password := r.PostFormValue("password")
402 scopes = strings.Split(r.PostFormValue("scope"), " ")
403 profile, err := authenticate(username, password, context)
405 if isAuthError(err) {
406 w.WriteHeader(http.StatusBadRequest)
407 renderJSONError(enc, "invalid_grant")
410 w.WriteHeader(http.StatusInternalServerError)
411 w.Write([]byte(err.Error()))
414 profileID = profile.ID
419 func credentialsAuditString(r *http.Request) string {