Start to support deleting profiles through the API.
Create a removeLoginsByProfile method on the profileStore, to allow an easy way
to bulk-delete logins associated with a Profile after the Profile has been
deleted.
Create postgres and memstore implementations of the removeLoginsByProfile
method.
Create a cleanUpAfterProfileDeletion helper method that will clean up the child
objects of a Profile (its Sessions, Tokens, Clients, etc.). The intended usage
is to call this in a goroutine after a Profile has been deleted, to try and get
things back in order.
Detect when the UpdateProfileHandler API is used to set the Deleted flag of a
Profile to true, and clean up after the Profile when that's the case.
Add a DeleteProfileHandler API endpoint that is a shortcut to setting the
Deleted flag of a Profile to true and cleaning up after the Profile.
The problem with our approach thus far is that some of it is reversible and some
is not. If a Profile is maliciously/accidentally deleted, it's simple enough to
use the API as a superuser to restore the Profile. But doing that will not (and
cannot) restore the Logins associated with that Profile, for example. While it
would be nice to add a Deleted flag to our Logins that we could simply toggle,
that would wreak havoc with our database constraints and ensuring uniqueness of
Login values. I still don't have a solution for this, outside the superuser
manually restoring a Login for the Profile, after which the user can
authenticate themselves and add more Logins as desired. But there has to be a
better way.
I suppose since the passphrase is being stored with the Profile and not the
Login, we could offer an endpoint that would automate this, but... well, that
would be tricky. It would require the user remembering their Profile ID, and
let's be honest, nobody's going to remember a UUID.
Maybe such an endpoint would help from a customer service standpoint: we
identify their Profile manually, then send them to /profiles/ID/restorelogin or
something, and that lets them add a Login back to the Profile.
I'll figure it out later. For now, we know we at least have enough information
to identify a user is who they say they are and resolve the situation manually.
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 {