Fix tests for scopeStore.
Update all our tests to use the PG_TEST_DB environment variable if set, and use
that to control whether or not the postgres tests get run. testing.Short() just
wasn't working.
Update ErrScopeNotFound and ErrScopeAlreadyExists to be variables instead of
types, because PostgreSQL (annoyingly) offers no way to determine which specific
row insertion caused the problem, and I anticipate this being a problem that is
ongoing. So it's really just not worth it.
Stop getScopes from returning an ErrScopeNotFound. Let's return what we find,
and let the absence of what we didn't find speak for itself.
Fix an error with generating the SQL for the postgres.createScopes call. We used
to generate it in a way that was invalid (not joining values with ",") when more
than one set of values was supplied. Hooray, testing!
Update the postgres scopeStore to return ErrScopeNotFound and
ErrScopeAlreadyExists errors, as appropriate.
Update our tests to reflect that ErrScopeNotFound and ErrScopeAlreadyExists are
now variables, not types.
12 "code.secondbit.org/uuid.hg"
13 "github.com/extemporalgenome/slug"
14 "github.com/gorilla/mux"
18 // MinPassphraseLength is the minimum length, in bytes, of a passphrase, exclusive.
19 MinPassphraseLength = 6
20 // MaxPassphraseLength is the maximum length, in bytes, of a passphrase, exclusive.
21 MaxPassphraseLength = 64
22 // CurPassphraseScheme is the current passphrase scheme. Incrememnt it when we use a different passphrase scheme
23 CurPassphraseScheme = 1
24 // MaxNameLength is the maximum length, in bytes, of a name, exclusive.
26 // MaxUsernameLength is the maximum length, in bytes, of a username, exclusive.
27 MaxUsernameLength = 16
28 // MaxEmailLength is the maximum length, in bytes, of an email address, exclusive.
33 // ErrNoProfileStore is returned when a Context tries to act on a profileStore without setting one first.
34 ErrNoProfileStore = errors.New("no profileStore was specified for the Context")
35 // ErrProfileAlreadyExists is returned when a Profile is added to a profileStore, but another Profile with
36 // the same ID already exists in the profileStore.
37 ErrProfileAlreadyExists = errors.New("profile already exists in profileStore")
38 // ErrProfileNotFound is returned when a Profile is requested but not found in the profileStore.
39 ErrProfileNotFound = errors.New("profile not found in profileStore")
40 // ErrLoginAlreadyExists is returned when a Login is added to a profileStore, but another Login with the same
41 // Type and Value already exists in the profileStore.
42 ErrLoginAlreadyExists = errors.New("login already exists in profileStore")
43 // ErrLoginNotFound is returned when a Login is requested but not found in the profileStore.
44 ErrLoginNotFound = errors.New("login not found in profileStore")
46 // ErrMissingPassphrase is returned when a ProfileChange is validated but does not contain a
47 // Passphrase, and requires one.
48 ErrMissingPassphrase = errors.New("missing passphrase")
49 // ErrMissingPassphraseReset is returned when a ProfileChange is validated but does not contain
50 // a PassphraseReset, and requires one.
51 ErrMissingPassphraseReset = errors.New("missing passphrase reset")
52 // ErrMissingPassphraseResetCreated is returned when a ProfileChange is validated but does not
53 // contain a PassphraseResetCreated, and requires one.
54 ErrMissingPassphraseResetCreated = errors.New("missing passphrase reset created timestamp")
55 // ErrPassphraseTooShort is returned when a ProfileChange is validated and contains a Passphrase,
56 // but the Passphrase is shorter than MinPassphraseLength.
57 ErrPassphraseTooShort = errors.New("passphrase too short")
58 // ErrPassphraseTooLong is returned when a ProfileChange is validated and contains a Passphrase,
59 // but the Passphrase is longer than MaxPassphraseLength.
60 ErrPassphraseTooLong = errors.New("passphrase too long")
62 // ErrProfileCompromised is returned when a user tries to log in with a profile that is suspected
63 // of being compromised.
64 ErrProfileCompromised = errors.New("profile compromised")
65 // ErrProfileLocked is returned when a user tries to log in with a profile that is locked for a certain
66 // duration, to prevent brute force attacks.
67 ErrProfileLocked = errors.New("profile locked")
70 // Profile represents a single user of the service,
71 // including their authentication information, but not
72 // including their username or email.
74 ID uuid.ID `json:"id,omitempty"`
75 Name string `json:"name,omitempty"`
76 Passphrase string `json:"-"`
77 Iterations int `json:"-"`
78 Salt string `json:"-"`
79 PassphraseScheme int `json:"-"`
80 Compromised bool `json:"-"`
81 LockedUntil time.Time `json:"-"`
82 PassphraseReset string `json:"-"`
83 PassphraseResetCreated time.Time `json:"-"`
84 Created time.Time `json:"created,omitempty"`
85 LastSeen time.Time `json:"last_seen,omitempty"`
86 Deleted bool `json:"deleted,omitempty"`
89 // ApplyChange applies the properties of the passed ProfileChange
90 // to the Profile it is called on.
91 func (p *Profile) ApplyChange(change ProfileChange) {
92 if change.Name != nil {
95 if change.Passphrase != nil {
96 p.Passphrase = *change.Passphrase
98 if change.Iterations != nil {
99 p.Iterations = *change.Iterations
101 if change.Salt != nil {
102 p.Salt = *change.Salt
104 if change.PassphraseScheme != nil {
105 p.PassphraseScheme = *change.PassphraseScheme
107 if change.Compromised != nil {
108 p.Compromised = *change.Compromised
110 if change.LockedUntil != nil {
111 p.LockedUntil = *change.LockedUntil
113 if change.PassphraseReset != nil {
114 p.PassphraseReset = *change.PassphraseReset
116 if change.PassphraseResetCreated != nil {
117 p.PassphraseResetCreated = *change.PassphraseResetCreated
119 if change.LastSeen != nil {
120 p.LastSeen = *change.LastSeen
122 if change.Deleted != nil {
123 p.Deleted = *change.Deleted
127 // ApplyBulkChange applies the properties of the passed BulkProfileChange
128 // to the Profile it is called on.
129 func (p *Profile) ApplyBulkChange(change BulkProfileChange) {
130 if change.Compromised != nil {
131 p.Compromised = *change.Compromised
135 // ProfileChange represents a single atomic change to a Profile's mutable data.
136 type ProfileChange struct {
141 PassphraseScheme *int
143 LockedUntil *time.Time
144 PassphraseReset *string
145 PassphraseResetCreated *time.Time
150 func (c ProfileChange) Empty() bool {
151 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)
154 // Validate checks the ProfileChange it is called on
155 // and asserts its internal validity, or lack thereof.
156 // A descriptive error will be returned in the case of
157 // an invalid change.
158 func (c ProfileChange) Validate() error {
160 return ErrEmptyChange
162 if c.PassphraseScheme != nil && c.Passphrase == nil {
163 return ErrMissingPassphrase
165 if c.PassphraseReset != nil && c.PassphraseResetCreated == nil {
166 return ErrMissingPassphraseResetCreated
168 if c.PassphraseReset == nil && c.PassphraseResetCreated != nil {
169 return ErrMissingPassphraseReset
171 if c.Salt != nil && c.Passphrase == nil {
172 return ErrMissingPassphrase
174 if c.Iterations != nil && c.Passphrase == nil {
175 return ErrMissingPassphrase
180 // BulkProfileChange represents a single atomic change to many Profiles' mutable data.
181 // It is a subset of a ProfileChange, as it doesn't make sense to mutate some of the
182 // ProfileChange values across many Profiles all at once.
183 type BulkProfileChange struct {
187 func (b BulkProfileChange) Empty() bool {
188 return b.Compromised == nil
191 // Validate checks the BulkProfileChange it is called on
192 // and asserts its internal validity, or lack thereof.
193 // A descriptive error will be returned in the case of an
195 func (b BulkProfileChange) Validate() error {
197 return ErrEmptyChange
202 // Login represents a single human-friendly identifier for
203 // a given Profile that can be used to log into that Profile.
204 // Each Profile may only have one Login for each Type.
206 Type string `json:"type,omitempty"`
207 Value string `json:"value,omitempty"`
208 ProfileID uuid.ID `json:"profile_id,omitempty"`
209 Created time.Time `json:"created,omitempty"`
210 LastUsed time.Time `json:"last_used,omitempty"`
213 type newProfileRequest struct {
214 Username string `json:"username"`
215 Email string `json:"email"`
216 Passphrase string `json:"passphrase"`
217 Name string `json:"name"`
220 func validateNewProfileRequest(req *newProfileRequest) []requestError {
221 errors := []requestError{}
222 req.Name = strings.TrimSpace(req.Name)
223 req.Email = strings.TrimSpace(req.Email)
224 req.Username = slug.SlugAscii(strings.TrimSpace(req.Username))
225 if len(req.Passphrase) < MinPassphraseLength {
226 errors = append(errors, requestError{
227 Slug: requestErrInsufficient,
228 Field: "/passphrase",
231 if len(req.Passphrase) > MaxPassphraseLength {
232 errors = append(errors, requestError{
233 Slug: requestErrOverflow,
234 Field: "/passphrase",
237 if len(req.Name) > MaxNameLength {
238 errors = append(errors, requestError{
239 Slug: requestErrOverflow,
243 if len(req.Username) > MaxUsernameLength {
244 errors = append(errors, requestError{
245 Slug: requestErrOverflow,
250 errors = append(errors, requestError{
251 Slug: requestErrMissing,
255 if len(req.Email) > MaxEmailLength {
256 errors = append(errors, requestError{
257 Slug: requestErrOverflow,
261 re := regexp.MustCompile(".+@.+\\..+")
262 if !re.Match([]byte(req.Email)) {
263 errors = append(errors, requestError{
264 Slug: requestErrInvalidFormat,
271 type profileStore interface {
272 getProfileByID(id uuid.ID) (Profile, error)
273 getProfileByLogin(value string) (Profile, error)
274 saveProfile(profile Profile) error
275 updateProfile(id uuid.ID, change ProfileChange) error
276 updateProfiles(ids []uuid.ID, change BulkProfileChange) error
278 addLogin(login Login) error
279 removeLogin(value string, profile uuid.ID) error
280 recordLoginUse(value string, when time.Time) error
281 listLogins(profile uuid.ID, num, offset int) ([]Login, error)
284 func (m *memstore) getProfileByID(id uuid.ID) (Profile, error) {
285 m.profileLock.RLock()
286 defer m.profileLock.RUnlock()
287 p, ok := m.profiles[id.String()]
289 return Profile{}, ErrProfileNotFound
292 return Profile{}, ErrProfileNotFound
297 func (m *memstore) getProfileByLogin(value string) (Profile, error) {
299 defer m.loginLock.RUnlock()
300 login, ok := m.logins[value]
302 return Profile{}, ErrLoginNotFound
304 m.profileLock.RLock()
305 defer m.profileLock.RUnlock()
306 profile, ok := m.profiles[login.ProfileID.String()]
308 return Profile{}, ErrProfileNotFound
311 return Profile{}, ErrProfileNotFound
316 func (m *memstore) saveProfile(profile Profile) error {
318 defer m.profileLock.Unlock()
319 _, ok := m.profiles[profile.ID.String()]
321 return ErrProfileAlreadyExists
323 m.profiles[profile.ID.String()] = profile
327 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error {
329 defer m.profileLock.Unlock()
330 p, ok := m.profiles[id.String()]
332 return ErrProfileNotFound
334 p.ApplyChange(change)
335 m.profiles[id.String()] = p
339 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error {
341 defer m.profileLock.Unlock()
342 for id, profile := range m.profiles {
343 for _, i := range ids {
344 if id == i.String() {
345 profile.ApplyBulkChange(change)
346 m.profiles[id] = profile
354 func (m *memstore) addLogin(login Login) error {
356 defer m.loginLock.Unlock()
357 _, ok := m.logins[login.Value]
359 return ErrLoginAlreadyExists
361 m.logins[login.Value] = login
362 m.profileLoginLookup[login.ProfileID.String()] = append(m.profileLoginLookup[login.ProfileID.String()], login.Value)
366 func (m *memstore) removeLogin(value string, profile uuid.ID) error {
368 defer m.loginLock.Unlock()
369 l, ok := m.logins[value]
371 return ErrLoginNotFound
373 if !l.ProfileID.Equal(profile) {
374 return ErrLoginNotFound
376 delete(m.logins, value)
378 for p, id := range m.profileLoginLookup[profile.String()] {
385 m.profileLoginLookup[profile.String()] = append(m.profileLoginLookup[profile.String()][:pos], m.profileLoginLookup[profile.String()][pos+1:]...)
390 func (m *memstore) recordLoginUse(value string, when time.Time) error {
392 defer m.loginLock.Unlock()
393 l, ok := m.logins[value]
395 return ErrLoginNotFound
402 func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) {
404 defer m.loginLock.RUnlock()
405 ids, ok := m.profileLoginLookup[profile.String()]
407 return []Login{}, nil
409 if len(ids) > num+offset {
410 ids = ids[offset : num+offset]
411 } else if len(ids) > offset {
414 return []Login{}, nil
417 for _, id := range ids {
418 login, ok := m.logins[id]
422 logins = append(logins, login)
427 // RegisterProfileHandlers adds handlers to the passed router to handle the profile endpoints, like registration and user retrieval.
428 func RegisterProfileHandlers(r *mux.Router, context Context) {
429 r.Handle("/profiles", wrap(context, CreateProfileHandler)).Methods("POST")
430 // BUG(paddy): We need to implement a handler that will return information about a profile or set of profiles.
431 r.Handle("/profiles/{id}", wrap(context, UpdateProfileHandler)).Methods("PATCH")
432 // 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?
433 // BUG(paddy): We need to implement a handler that will add a login to a profile.
434 // BUG(paddy): We need to implement a handler that will remove a login from a profile. What happens to sessions created with that login?
435 // BUG(paddy): We need to implement a handler that will list the logins attached to a profile.
438 // CreateProfileHandler is an HTTP handler for registering new profiles.
439 func CreateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
440 scheme, ok := passphraseSchemes[CurPassphraseScheme]
442 log.Printf("Error selecting passphrase scheme #%d\n", CurPassphraseScheme)
443 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
446 var req newProfileRequest
447 errors := []requestError{}
448 decoder := json.NewDecoder(r.Body)
449 err := decoder.Decode(&req)
451 encode(w, r, http.StatusBadRequest, invalidFormatResponse)
454 errors = append(errors, validateNewProfileRequest(&req)...)
456 encode(w, r, http.StatusBadRequest, response{Errors: errors})
459 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations)
461 log.Printf("Error creating encoded passphrase: %#+v\n", err)
462 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
468 Passphrase: string(passphrase),
469 Iterations: context.config.iterations,
471 PassphraseScheme: CurPassphraseScheme,
473 LastSeen: time.Now(),
475 err = context.SaveProfile(profile)
477 if err == ErrProfileAlreadyExists {
478 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/id"}}})
481 log.Printf("Error saving profile: %#+v\n", err)
482 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
489 Created: profile.Created,
490 LastUsed: profile.Created,
491 ProfileID: profile.ID,
493 err = context.AddLogin(login)
495 if err == ErrLoginAlreadyExists {
496 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/email"}}})
499 log.Printf("Error adding login: %#+v\n", err)
500 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
503 logins = append(logins, login)
504 if req.Username != "" {
505 login.Type = "username"
506 login.Value = req.Username
507 err = context.AddLogin(login)
509 if err == ErrLoginAlreadyExists {
510 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/username"}}})
513 log.Printf("Error adding login: %#+v\n", err)
514 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
517 logins = append(logins, login)
521 Profiles: []Profile{profile},
523 encode(w, r, http.StatusCreated, resp)
524 // TODO(paddy): should we kick off the email validation flow?
527 func UpdateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
528 errors := []requestError{}
530 if vars["id"] == "" {
531 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
532 encode(w, r, http.StatusBadRequest, response{Errors: errors})
535 id, err := uuid.Parse(vars["id"])
537 errors = append(errors, requestError{Slug: requestErrAccessDenied})
538 encode(w, r, http.StatusBadRequest, response{Errors: errors})
541 username, password, ok := r.BasicAuth()
543 errors = append(errors, requestError{Slug: requestErrAccessDenied})
544 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
547 profile, err := authenticate(username, password, context)
549 if isAuthError(err) {
550 errors = append(errors, requestError{Slug: requestErrAccessDenied})
551 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
553 errors = append(errors, requestError{Slug: requestErrActOfGod})
554 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
558 if !profile.ID.Equal(id) {
559 errors = append(errors, requestError{Slug: requestErrAccessDenied})
560 encode(w, r, http.StatusForbidden, response{Errors: errors})
563 var req ProfileChange
564 decoder := json.NewDecoder(r.Body)
565 err = decoder.Decode(&req)
567 log.Printf("Error decoding request: %#+v\n", err)
568 encode(w, r, http.StatusBadRequest, invalidFormatResponse)
573 req.PassphraseScheme = nil
574 req.Compromised = nil // BUG(paddy): Need a way for admins to mark accounts as compromised
575 req.LockedUntil = nil
577 if req.Passphrase != nil {
578 if len(*req.Passphrase) < MinPassphraseLength {
579 errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/passphrase"})
580 encode(w, r, http.StatusBadRequest, response{Errors: errors})
583 if len(*req.Passphrase) > MaxPassphraseLength {
584 errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/passphrase"})
585 encode(w, r, http.StatusBadRequest, response{Errors: errors})
588 iterations := context.config.iterations
589 scheme, ok := passphraseSchemes[CurPassphraseScheme]
591 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
594 curScheme := CurPassphraseScheme
595 req.PassphraseScheme = &curScheme
596 passphrase, salt, err := scheme.create(*req.Passphrase, iterations)
598 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
601 req.Passphrase = &passphrase
603 req.Iterations = &iterations
605 if req.PassphraseReset != nil {
607 req.PassphraseResetCreated = &now
615 resp.Profiles = []Profile{profile}
616 status = http.StatusOK
618 errors = append(errors, requestError{Slug: requestErrActOfGod})
620 status = http.StatusInternalServerError
622 encode(w, r, status, resp)
625 err = context.UpdateProfile(id, req)
627 if err == ErrProfileNotFound {
628 errors = append(errors, requestError{Slug: requestErrNotFound})
629 encode(w, r, http.StatusNotFound, response{Errors: errors})
632 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
635 profile.ApplyChange(req)
636 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}})