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.
12 "code.secondbit.org/uuid.hg"
13 "github.com/gorilla/mux"
17 // MinPassphraseLength is the minimum length, in bytes, of a passphrase, exclusive.
18 MinPassphraseLength = 6
19 // MaxPassphraseLength is the maximum length, in bytes, of a passphrase, exclusive.
20 MaxPassphraseLength = 64
21 // CurPassphraseScheme is the current passphrase scheme. Incrememnt it when we use a different passphrase scheme
22 CurPassphraseScheme = 1
23 // MaxNameLength is the maximum length, in bytes, of a name, exclusive.
25 // MaxEmailLength is the maximum length, in bytes, of an email address, exclusive.
30 // ErrNoProfileStore is returned when a Context tries to act on a profileStore without setting one first.
31 ErrNoProfileStore = errors.New("no profileStore was specified for the Context")
32 // ErrProfileAlreadyExists is returned when a Profile is added to a profileStore, but another Profile with
33 // the same ID already exists in the profileStore.
34 ErrProfileAlreadyExists = errors.New("profile already exists in profileStore")
35 // ErrProfileNotFound is returned when a Profile is requested but not found in the profileStore.
36 ErrProfileNotFound = errors.New("profile not found in profileStore")
37 // ErrLoginAlreadyExists is returned when a Login is added to a profileStore, but another Login with the same
38 // Type and Value already exists in the profileStore.
39 ErrLoginAlreadyExists = errors.New("login already exists in profileStore")
40 // ErrLoginNotFound is returned when a Login is requested but not found in the profileStore.
41 ErrLoginNotFound = errors.New("login not found in profileStore")
43 // ErrMissingPassphrase is returned when a ProfileChange is validated but does not contain a
44 // Passphrase, and requires one.
45 ErrMissingPassphrase = errors.New("missing passphrase")
46 // ErrMissingPassphraseReset is returned when a ProfileChange is validated but does not contain
47 // a PassphraseReset, and requires one.
48 ErrMissingPassphraseReset = errors.New("missing passphrase reset")
49 // ErrMissingPassphraseResetCreated is returned when a ProfileChange is validated but does not
50 // contain a PassphraseResetCreated, and requires one.
51 ErrMissingPassphraseResetCreated = errors.New("missing passphrase reset created timestamp")
52 // ErrPassphraseTooShort is returned when a ProfileChange is validated and contains a Passphrase,
53 // but the Passphrase is shorter than MinPassphraseLength.
54 ErrPassphraseTooShort = errors.New("passphrase too short")
55 // ErrPassphraseTooLong is returned when a ProfileChange is validated and contains a Passphrase,
56 // but the Passphrase is longer than MaxPassphraseLength.
57 ErrPassphraseTooLong = errors.New("passphrase too long")
59 // ErrProfileCompromised is returned when a user tries to log in with a profile that is suspected
60 // of being compromised.
61 ErrProfileCompromised = errors.New("profile compromised")
62 // ErrProfileLocked is returned when a user tries to log in with a profile that is locked for a certain
63 // duration, to prevent brute force attacks.
64 ErrProfileLocked = errors.New("profile locked")
67 // Profile represents a single user of the service,
68 // including their authentication information.
70 ID uuid.ID `json:"id,omitempty"`
71 Name string `json:"name,omitempty"`
72 Passphrase string `json:"-"`
73 Iterations int `json:"-"`
74 Salt string `json:"-"`
75 PassphraseScheme int `json:"-"`
76 Compromised bool `json:"-"`
77 LockedUntil time.Time `json:"-"`
78 PassphraseReset string `json:"-"`
79 PassphraseResetCreated time.Time `json:"-"`
80 Created time.Time `json:"created,omitempty"`
81 LastSeen time.Time `json:"last_seen,omitempty"`
82 Deleted bool `json:"deleted,omitempty"`
85 // ApplyChange applies the properties of the passed ProfileChange
86 // to the Profile it is called on.
87 func (p *Profile) ApplyChange(change ProfileChange) {
88 if change.Name != nil {
91 if change.Passphrase != nil {
92 p.Passphrase = *change.Passphrase
94 if change.Iterations != nil {
95 p.Iterations = *change.Iterations
97 if change.Salt != nil {
100 if change.PassphraseScheme != nil {
101 p.PassphraseScheme = *change.PassphraseScheme
103 if change.Compromised != nil {
104 p.Compromised = *change.Compromised
106 if change.LockedUntil != nil {
107 p.LockedUntil = *change.LockedUntil
109 if change.PassphraseReset != nil {
110 p.PassphraseReset = *change.PassphraseReset
112 if change.PassphraseResetCreated != nil {
113 p.PassphraseResetCreated = *change.PassphraseResetCreated
115 if change.LastSeen != nil {
116 p.LastSeen = *change.LastSeen
118 if change.Deleted != nil {
119 p.Deleted = *change.Deleted
123 // ApplyBulkChange applies the properties of the passed BulkProfileChange
124 // to the Profile it is called on.
125 func (p *Profile) ApplyBulkChange(change BulkProfileChange) {
126 if change.Compromised != nil {
127 p.Compromised = *change.Compromised
131 // ProfileChange represents a single atomic change to a Profile's mutable data.
132 type ProfileChange struct {
137 PassphraseScheme *int
139 LockedUntil *time.Time
140 PassphraseReset *string
141 PassphraseResetCreated *time.Time
146 func (c ProfileChange) Empty() bool {
147 return (c.Name == nil && c.Passphrase == nil && c.Iterations == nil && c.Salt == nil && c.PassphraseScheme == nil && c.Compromised == nil && c.LockedUntil == nil && c.PassphraseReset == nil && c.PassphraseResetCreated == nil && c.LastSeen == nil && c.Deleted == nil)
150 // Validate checks the ProfileChange it is called on
151 // and asserts its internal validity, or lack thereof.
152 // A descriptive error will be returned in the case of
153 // an invalid change.
154 func (c ProfileChange) Validate() error {
156 return ErrEmptyChange
158 if c.PassphraseScheme != nil && c.Passphrase == nil {
159 return ErrMissingPassphrase
161 if c.PassphraseReset != nil && c.PassphraseResetCreated == nil {
162 return ErrMissingPassphraseResetCreated
164 if c.PassphraseReset == nil && c.PassphraseResetCreated != nil {
165 return ErrMissingPassphraseReset
167 if c.Salt != nil && c.Passphrase == nil {
168 return ErrMissingPassphrase
170 if c.Iterations != nil && c.Passphrase == nil {
171 return ErrMissingPassphrase
176 // BulkProfileChange represents a single atomic change to many Profiles' mutable data.
177 // It is a subset of a ProfileChange, as it doesn't make sense to mutate some of the
178 // ProfileChange values across many Profiles all at once.
179 type BulkProfileChange struct {
183 func (b BulkProfileChange) Empty() bool {
184 return b.Compromised == nil
187 // Validate checks the BulkProfileChange it is called on
188 // and asserts its internal validity, or lack thereof.
189 // A descriptive error will be returned in the case of an
191 func (b BulkProfileChange) Validate() error {
193 return ErrEmptyChange
198 // Login represents a single human-friendly identifier for
199 // a given Profile that can be used to log into that Profile.
200 // Each Profile may only have one Login for each Type.
202 Type string `json:"type,omitempty"`
203 Value string `json:"value,omitempty"`
204 ProfileID uuid.ID `json:"profile_id,omitempty"`
205 Created time.Time `json:"created,omitempty"`
206 LastUsed time.Time `json:"last_used,omitempty"`
209 type newProfileRequest struct {
210 Email string `json:"email"`
211 Passphrase string `json:"passphrase"`
212 Name string `json:"name"`
215 func validateNewProfileRequest(req *newProfileRequest) []requestError {
216 errors := []requestError{}
217 req.Name = strings.TrimSpace(req.Name)
218 req.Email = strings.TrimSpace(req.Email)
219 if len(req.Passphrase) < MinPassphraseLength {
220 errors = append(errors, requestError{
221 Slug: requestErrInsufficient,
222 Field: "/passphrase",
225 if len(req.Passphrase) > MaxPassphraseLength {
226 errors = append(errors, requestError{
227 Slug: requestErrOverflow,
228 Field: "/passphrase",
231 if len(req.Name) > MaxNameLength {
232 errors = append(errors, requestError{
233 Slug: requestErrOverflow,
238 errors = append(errors, requestError{
239 Slug: requestErrMissing,
243 if len(req.Email) > MaxEmailLength {
244 errors = append(errors, requestError{
245 Slug: requestErrOverflow,
249 re := regexp.MustCompile(".+@.+\\..+")
250 if !re.Match([]byte(req.Email)) {
251 errors = append(errors, requestError{
252 Slug: requestErrInvalidFormat,
259 type profileStore interface {
260 getProfileByID(id uuid.ID) (Profile, error)
261 getProfileByLogin(value string) (Profile, error)
262 saveProfile(profile Profile) error
263 updateProfile(id uuid.ID, change ProfileChange) error
264 updateProfiles(ids []uuid.ID, change BulkProfileChange) error
266 addLogin(login Login) error
267 removeLogin(value string, profile uuid.ID) error
268 recordLoginUse(value string, when time.Time) error
269 listLogins(profile uuid.ID, num, offset int) ([]Login, error)
272 func (m *memstore) getProfileByID(id uuid.ID) (Profile, error) {
273 m.profileLock.RLock()
274 defer m.profileLock.RUnlock()
275 p, ok := m.profiles[id.String()]
277 return Profile{}, ErrProfileNotFound
280 return Profile{}, ErrProfileNotFound
285 func (m *memstore) getProfileByLogin(value string) (Profile, error) {
287 defer m.loginLock.RUnlock()
288 login, ok := m.logins[value]
290 return Profile{}, ErrLoginNotFound
292 m.profileLock.RLock()
293 defer m.profileLock.RUnlock()
294 profile, ok := m.profiles[login.ProfileID.String()]
296 return Profile{}, ErrProfileNotFound
299 return Profile{}, ErrProfileNotFound
304 func (m *memstore) saveProfile(profile Profile) error {
306 defer m.profileLock.Unlock()
307 _, ok := m.profiles[profile.ID.String()]
309 return ErrProfileAlreadyExists
311 m.profiles[profile.ID.String()] = profile
315 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error {
317 defer m.profileLock.Unlock()
318 p, ok := m.profiles[id.String()]
320 return ErrProfileNotFound
322 p.ApplyChange(change)
323 m.profiles[id.String()] = p
327 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error {
329 defer m.profileLock.Unlock()
330 for id, profile := range m.profiles {
331 for _, i := range ids {
332 if id == i.String() {
333 profile.ApplyBulkChange(change)
334 m.profiles[id] = profile
342 func (m *memstore) addLogin(login Login) error {
344 defer m.loginLock.Unlock()
345 _, ok := m.logins[login.Value]
347 return ErrLoginAlreadyExists
349 m.logins[login.Value] = login
350 m.profileLoginLookup[login.ProfileID.String()] = append(m.profileLoginLookup[login.ProfileID.String()], login.Value)
354 func (m *memstore) removeLogin(value string, profile uuid.ID) error {
356 defer m.loginLock.Unlock()
357 l, ok := m.logins[value]
359 return ErrLoginNotFound
361 if !l.ProfileID.Equal(profile) {
362 return ErrLoginNotFound
364 delete(m.logins, value)
366 for p, id := range m.profileLoginLookup[profile.String()] {
373 m.profileLoginLookup[profile.String()] = append(m.profileLoginLookup[profile.String()][:pos], m.profileLoginLookup[profile.String()][pos+1:]...)
378 func (m *memstore) recordLoginUse(value string, when time.Time) error {
380 defer m.loginLock.Unlock()
381 l, ok := m.logins[value]
383 return ErrLoginNotFound
390 func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) {
392 defer m.loginLock.RUnlock()
393 ids, ok := m.profileLoginLookup[profile.String()]
395 return []Login{}, nil
397 if len(ids) > num+offset {
398 ids = ids[offset : num+offset]
399 } else if len(ids) > offset {
402 return []Login{}, nil
405 for _, id := range ids {
406 login, ok := m.logins[id]
410 logins = append(logins, login)
415 // RegisterProfileHandlers adds handlers to the passed router to handle the profile endpoints, like registration and user retrieval.
416 func RegisterProfileHandlers(r *mux.Router, context Context) {
417 r.Handle("/profiles", wrap(context, CreateProfileHandler)).Methods("POST")
418 // BUG(paddy): We need to implement a handler that will return information about a profile or set of profiles.
419 r.Handle("/profiles/{id}", wrap(context, UpdateProfileHandler)).Methods("PATCH")
420 // BUG(paddy): We need to implement a handler that will delete a profile. What happens to clients/tokens/grants/sessions when a profile is deleted?
421 // BUG(paddy): We need to implement a handler that will add a login to a profile.
422 // BUG(paddy): We need to implement a handler that will remove a login from a profile. What happens to sessions created with that login?
423 // BUG(paddy): We need to implement a handler that will list the logins attached to a profile.
426 // CreateProfileHandler is an HTTP handler for registering new profiles.
427 func CreateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
428 scheme, ok := passphraseSchemes[CurPassphraseScheme]
430 log.Printf("Error selecting passphrase scheme #%d\n", CurPassphraseScheme)
431 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
434 var req newProfileRequest
435 errors := []requestError{}
436 decoder := json.NewDecoder(r.Body)
437 err := decoder.Decode(&req)
439 encode(w, r, http.StatusBadRequest, invalidFormatResponse)
442 errors = append(errors, validateNewProfileRequest(&req)...)
444 encode(w, r, http.StatusBadRequest, response{Errors: errors})
447 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations)
449 log.Printf("Error creating encoded passphrase: %#+v\n", err)
450 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
456 Passphrase: string(passphrase),
457 Iterations: context.config.iterations,
459 PassphraseScheme: CurPassphraseScheme,
461 LastSeen: time.Now(),
463 err = context.SaveProfile(profile)
465 if err == ErrProfileAlreadyExists {
466 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/id"}}})
469 log.Printf("Error saving profile: %#+v\n", err)
470 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
477 Created: profile.Created,
478 LastUsed: profile.Created,
479 ProfileID: profile.ID,
481 err = context.AddLogin(login)
483 if err == ErrLoginAlreadyExists {
484 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/email"}}})
487 log.Printf("Error adding login: %#+v\n", err)
488 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
491 logins = append(logins, login)
494 Profiles: []Profile{profile},
496 encode(w, r, http.StatusCreated, resp)
497 // TODO(paddy): should we kick off the email validation flow?
500 func UpdateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
501 errors := []requestError{}
503 if vars["id"] == "" {
504 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
505 encode(w, r, http.StatusBadRequest, response{Errors: errors})
508 id, err := uuid.Parse(vars["id"])
510 errors = append(errors, requestError{Slug: requestErrAccessDenied})
511 encode(w, r, http.StatusBadRequest, response{Errors: errors})
514 username, password, ok := r.BasicAuth()
516 errors = append(errors, requestError{Slug: requestErrAccessDenied})
517 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
520 profile, err := authenticate(username, password, context)
522 if isAuthError(err) {
523 errors = append(errors, requestError{Slug: requestErrAccessDenied})
524 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
526 errors = append(errors, requestError{Slug: requestErrActOfGod})
527 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
531 if !profile.ID.Equal(id) {
532 errors = append(errors, requestError{Slug: requestErrAccessDenied})
533 encode(w, r, http.StatusForbidden, response{Errors: errors})
536 var req ProfileChange
537 decoder := json.NewDecoder(r.Body)
538 err = decoder.Decode(&req)
540 log.Printf("Error decoding request: %#+v\n", err)
541 encode(w, r, http.StatusBadRequest, invalidFormatResponse)
546 req.PassphraseScheme = nil
547 req.Compromised = nil // BUG(paddy): Need a way for admins to mark accounts as compromised
548 req.LockedUntil = nil
550 if req.Passphrase != nil {
551 if len(*req.Passphrase) < MinPassphraseLength {
552 errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/passphrase"})
553 encode(w, r, http.StatusBadRequest, response{Errors: errors})
556 if len(*req.Passphrase) > MaxPassphraseLength {
557 errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/passphrase"})
558 encode(w, r, http.StatusBadRequest, response{Errors: errors})
561 iterations := context.config.iterations
562 scheme, ok := passphraseSchemes[CurPassphraseScheme]
564 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
567 curScheme := CurPassphraseScheme
568 req.PassphraseScheme = &curScheme
569 passphrase, salt, err := scheme.create(*req.Passphrase, iterations)
571 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
574 req.Passphrase = &passphrase
576 req.Iterations = &iterations
578 if req.PassphraseReset != nil {
580 req.PassphraseResetCreated = &now
588 resp.Profiles = []Profile{profile}
589 status = http.StatusOK
591 errors = append(errors, requestError{Slug: requestErrActOfGod})
593 status = http.StatusInternalServerError
595 encode(w, r, status, resp)
598 err = context.UpdateProfile(id, req)
600 if err == ErrProfileNotFound {
601 errors = append(errors, requestError{Slug: requestErrNotFound})
602 encode(w, r, http.StatusNotFound, response{Errors: errors})
605 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
608 profile.ApplyChange(req)
609 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}})