auth
auth/profile.go
Implement UpdateProfileHandler. Implement a handler that will allow users to update their Profiles through the API.
1 package auth
3 import (
4 "encoding/json"
5 "errors"
6 "net/http"
7 "regexp"
8 "strings"
9 "time"
11 "code.secondbit.org/uuid.hg"
12 "github.com/extemporalgenome/slug"
13 "github.com/gorilla/mux"
14 )
16 const (
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.
24 MaxNameLength = 64
25 // MaxUsernameLength is the maximum length, in bytes, of a username, exclusive.
26 MaxUsernameLength = 16
27 // MaxEmailLength is the maximum length, in bytes, of an email address, exclusive.
28 MaxEmailLength = 64
29 )
31 var (
32 // ErrNoProfileStore is returned when a Context tries to act on a profileStore without setting one first.
33 ErrNoProfileStore = errors.New("no profileStore was specified for the Context")
34 // ErrProfileAlreadyExists is returned when a Profile is added to a profileStore, but another Profile with
35 // the same ID already exists in the profileStore.
36 ErrProfileAlreadyExists = errors.New("profile already exists in profileStore")
37 // ErrProfileNotFound is returned when a Profile is requested but not found in the profileStore.
38 ErrProfileNotFound = errors.New("profile not found in profileStore")
39 // ErrLoginAlreadyExists is returned when a Login is added to a profileStore, but another Login with the same
40 // Type and Value already exists in the profileStore.
41 ErrLoginAlreadyExists = errors.New("login already exists in profileStore")
42 // ErrLoginNotFound is returned when a Login is requested but not found in the profileStore.
43 ErrLoginNotFound = errors.New("login not found in profileStore")
45 // ErrMissingPassphrase is returned when a ProfileChange is validated but does not contain a
46 // Passphrase, and requires one.
47 ErrMissingPassphrase = errors.New("missing passphrase")
48 // ErrMissingPassphraseReset is returned when a ProfileChange is validated but does not contain
49 // a PassphraseReset, and requires one.
50 ErrMissingPassphraseReset = errors.New("missing passphrase reset")
51 // ErrMissingPassphraseResetCreated is returned when a ProfileChange is validated but does not
52 // contain a PassphraseResetCreated, and requires one.
53 ErrMissingPassphraseResetCreated = errors.New("missing passphrase reset created timestamp")
54 // ErrPassphraseTooShort is returned when a ProfileChange is validated and contains a Passphrase,
55 // but the Passphrase is shorter than MinPassphraseLength.
56 ErrPassphraseTooShort = errors.New("passphrase too short")
57 // ErrPassphraseTooLong is returned when a ProfileChange is validated and contains a Passphrase,
58 // but the Passphrase is longer than MaxPassphraseLength.
59 ErrPassphraseTooLong = errors.New("passphrase too long")
61 // ErrProfileCompromised is returned when a user tries to log in with a profile that is suspected
62 // of being compromised.
63 ErrProfileCompromised = errors.New("profile compromised")
64 // ErrProfileLocked is returned when a user tries to log in with a profile that is locked for a certain
65 // duration, to prevent brute force attacks.
66 ErrProfileLocked = errors.New("profile locked")
67 )
69 // Profile represents a single user of the service,
70 // including their authentication information, but not
71 // including their username or email.
72 type Profile struct {
73 ID uuid.ID `json:"id,omitempty"`
74 Name string `json:"name,omitempty"`
75 Passphrase string `json:"-"`
76 Iterations int `json:"-"`
77 Salt string `json:"-"`
78 PassphraseScheme int `json:"-"`
79 Compromised bool `json:"-"`
80 LockedUntil time.Time `json:"-"`
81 PassphraseReset string `json:"-"`
82 PassphraseResetCreated time.Time `json:"-"`
83 Created time.Time `json:"created,omitempty"`
84 LastSeen time.Time `json:"last_seen,omitempty"`
85 }
87 // ApplyChange applies the properties of the passed ProfileChange
88 // to the Profile it is called on.
89 func (p *Profile) ApplyChange(change ProfileChange) {
90 if change.Name != nil {
91 p.Name = *change.Name
92 }
93 if change.Passphrase != nil {
94 p.Passphrase = *change.Passphrase
95 }
96 if change.Iterations != nil {
97 p.Iterations = *change.Iterations
98 }
99 if change.Salt != nil {
100 p.Salt = *change.Salt
101 }
102 if change.PassphraseScheme != nil {
103 p.PassphraseScheme = *change.PassphraseScheme
104 }
105 if change.Compromised != nil {
106 p.Compromised = *change.Compromised
107 }
108 if change.LockedUntil != nil {
109 p.LockedUntil = *change.LockedUntil
110 }
111 if change.PassphraseReset != nil {
112 p.PassphraseReset = *change.PassphraseReset
113 }
114 if change.PassphraseResetCreated != nil {
115 p.PassphraseResetCreated = *change.PassphraseResetCreated
116 }
117 if change.LastSeen != nil {
118 p.LastSeen = *change.LastSeen
119 }
120 }
122 // ApplyBulkChange applies the properties of the passed BulkProfileChange
123 // to the Profile it is called on.
124 func (p *Profile) ApplyBulkChange(change BulkProfileChange) {
125 if change.Compromised != nil {
126 p.Compromised = *change.Compromised
127 }
128 }
130 // ProfileChange represents a single atomic change to a Profile's mutable data.
131 type ProfileChange struct {
132 Name *string
133 Passphrase *string
134 Iterations *int
135 Salt *string
136 PassphraseScheme *int
137 Compromised *bool
138 LockedUntil *time.Time
139 PassphraseReset *string
140 PassphraseResetCreated *time.Time
141 LastSeen *time.Time
142 }
144 // Validate checks the ProfileChange it is called on
145 // and asserts its internal validity, or lack thereof.
146 // A descriptive error will be returned in the case of
147 // an invalid change.
148 func (c ProfileChange) Validate() error {
149 if 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 {
150 return ErrEmptyChange
151 }
152 if c.PassphraseScheme != nil && c.Passphrase == nil {
153 return ErrMissingPassphrase
154 }
155 if c.PassphraseReset != nil && c.PassphraseResetCreated == nil {
156 return ErrMissingPassphraseResetCreated
157 }
158 if c.PassphraseReset == nil && c.PassphraseResetCreated != nil {
159 return ErrMissingPassphraseReset
160 }
161 if c.Salt != nil && c.Passphrase == nil {
162 return ErrMissingPassphrase
163 }
164 if c.Iterations != nil && c.Passphrase == nil {
165 return ErrMissingPassphrase
166 }
167 return nil
168 }
170 // BulkProfileChange represents a single atomic change to many Profiles' mutable data.
171 // It is a subset of a ProfileChange, as it doesn't make sense to mutate some of the
172 // ProfileChange values across many Profiles all at once.
173 type BulkProfileChange struct {
174 Compromised *bool
175 }
177 // Validate checks the BulkProfileChange it is called on
178 // and asserts its internal validity, or lack thereof.
179 // A descriptive error will be returned in the case of an
180 // invalid change.
181 func (b BulkProfileChange) Validate() error {
182 if b.Compromised == nil {
183 return ErrEmptyChange
184 }
185 return nil
186 }
188 // Login represents a single human-friendly identifier for
189 // a given Profile that can be used to log into that Profile.
190 // Each Profile may only have one Login for each Type.
191 type Login struct {
192 Type string `json:"type,omitempty"`
193 Value string `json:"value,omitempty"`
194 ProfileID uuid.ID `json:"profile_id,omitempty"`
195 Created time.Time `json:"created,omitempty"`
196 LastUsed time.Time `json:"last_used,omitempty"`
197 }
199 type newProfileRequest struct {
200 Username string `json:"username"`
201 Email string `json:"email"`
202 Passphrase string `json:"passphrase"`
203 Name string `json:"name"`
204 }
206 func validateNewProfileRequest(req *newProfileRequest) []requestError {
207 errors := []requestError{}
208 req.Name = strings.TrimSpace(req.Name)
209 req.Email = strings.TrimSpace(req.Email)
210 req.Username = slug.SlugAscii(strings.TrimSpace(req.Username))
211 if len(req.Passphrase) < MinPassphraseLength {
212 errors = append(errors, requestError{
213 Slug: requestErrInsufficient,
214 Field: "/passphrase",
215 })
216 }
217 if len(req.Passphrase) > MaxPassphraseLength {
218 errors = append(errors, requestError{
219 Slug: requestErrOverflow,
220 Field: "/passphrase",
221 })
222 }
223 if len(req.Name) > MaxNameLength {
224 errors = append(errors, requestError{
225 Slug: requestErrOverflow,
226 Field: "/name",
227 })
228 }
229 if len(req.Username) > MaxUsernameLength {
230 errors = append(errors, requestError{
231 Slug: requestErrOverflow,
232 Field: "/username",
233 })
234 }
235 if req.Email == "" {
236 errors = append(errors, requestError{
237 Slug: requestErrMissing,
238 Field: "/email",
239 })
240 }
241 if len(req.Email) > MaxEmailLength {
242 errors = append(errors, requestError{
243 Slug: requestErrOverflow,
244 Field: "/email",
245 })
246 }
247 re := regexp.MustCompile(".+@.+\\..+")
248 if !re.Match([]byte(req.Email)) {
249 errors = append(errors, requestError{
250 Slug: requestErrInvalidFormat,
251 Field: "/email",
252 })
253 }
254 return errors
255 }
257 type profileStore interface {
258 getProfileByID(id uuid.ID) (Profile, error)
259 getProfileByLogin(value string) (Profile, error)
260 saveProfile(profile Profile) error
261 updateProfile(id uuid.ID, change ProfileChange) error
262 updateProfiles(ids []uuid.ID, change BulkProfileChange) error
263 deleteProfile(id uuid.ID) error
265 addLogin(login Login) error
266 removeLogin(value string, profile uuid.ID) error
267 recordLoginUse(value string, when time.Time) error
268 listLogins(profile uuid.ID, num, offset int) ([]Login, error)
269 }
271 func (m *memstore) getProfileByID(id uuid.ID) (Profile, error) {
272 m.profileLock.RLock()
273 defer m.profileLock.RUnlock()
274 p, ok := m.profiles[id.String()]
275 if !ok {
276 return Profile{}, ErrProfileNotFound
277 }
278 return p, nil
279 }
281 func (m *memstore) getProfileByLogin(value string) (Profile, error) {
282 m.loginLock.RLock()
283 defer m.loginLock.RUnlock()
284 login, ok := m.logins[value]
285 if !ok {
286 return Profile{}, ErrLoginNotFound
287 }
288 m.profileLock.RLock()
289 defer m.profileLock.RUnlock()
290 profile, ok := m.profiles[login.ProfileID.String()]
291 if !ok {
292 return Profile{}, ErrProfileNotFound
293 }
294 return profile, nil
295 }
297 func (m *memstore) saveProfile(profile Profile) error {
298 m.profileLock.Lock()
299 defer m.profileLock.Unlock()
300 _, ok := m.profiles[profile.ID.String()]
301 if ok {
302 return ErrProfileAlreadyExists
303 }
304 m.profiles[profile.ID.String()] = profile
305 return nil
306 }
308 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error {
309 m.profileLock.Lock()
310 defer m.profileLock.Unlock()
311 p, ok := m.profiles[id.String()]
312 if !ok {
313 return ErrProfileNotFound
314 }
315 p.ApplyChange(change)
316 m.profiles[id.String()] = p
317 return nil
318 }
320 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error {
321 m.profileLock.Lock()
322 defer m.profileLock.Unlock()
323 for id, profile := range m.profiles {
324 for _, i := range ids {
325 if id == i.String() {
326 profile.ApplyBulkChange(change)
327 m.profiles[id] = profile
328 break
329 }
330 }
331 }
332 return nil
333 }
335 func (m *memstore) deleteProfile(id uuid.ID) error {
336 m.profileLock.Lock()
337 defer m.profileLock.Unlock()
338 _, ok := m.profiles[id.String()]
339 if !ok {
340 return ErrProfileNotFound
341 }
342 delete(m.profiles, id.String())
343 return nil
344 }
346 func (m *memstore) addLogin(login Login) error {
347 m.loginLock.Lock()
348 defer m.loginLock.Unlock()
349 _, ok := m.logins[login.Value]
350 if ok {
351 return ErrLoginAlreadyExists
352 }
353 m.logins[login.Value] = login
354 m.profileLoginLookup[login.ProfileID.String()] = append(m.profileLoginLookup[login.ProfileID.String()], login.Value)
355 return nil
356 }
358 func (m *memstore) removeLogin(value string, profile uuid.ID) error {
359 m.loginLock.Lock()
360 defer m.loginLock.Unlock()
361 l, ok := m.logins[value]
362 if !ok {
363 return ErrLoginNotFound
364 }
365 if !l.ProfileID.Equal(profile) {
366 return ErrLoginNotFound
367 }
368 delete(m.logins, value)
369 pos := -1
370 for p, id := range m.profileLoginLookup[profile.String()] {
371 if id == value {
372 pos = p
373 break
374 }
375 }
376 if pos >= 0 {
377 m.profileLoginLookup[profile.String()] = append(m.profileLoginLookup[profile.String()][:pos], m.profileLoginLookup[profile.String()][pos+1:]...)
378 }
379 return nil
380 }
382 func (m *memstore) recordLoginUse(value string, when time.Time) error {
383 m.loginLock.Lock()
384 defer m.loginLock.Unlock()
385 l, ok := m.logins[value]
386 if !ok {
387 return ErrLoginNotFound
388 }
389 l.LastUsed = when
390 m.logins[value] = l
391 return nil
392 }
394 func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) {
395 m.loginLock.RLock()
396 defer m.loginLock.RUnlock()
397 ids, ok := m.profileLoginLookup[profile.String()]
398 if !ok {
399 return []Login{}, nil
400 }
401 if len(ids) > num+offset {
402 ids = ids[offset : num+offset]
403 } else if len(ids) > offset {
404 ids = ids[offset:]
405 } else {
406 return []Login{}, nil
407 }
408 logins := []Login{}
409 for _, id := range ids {
410 login, ok := m.logins[id]
411 if !ok {
412 continue
413 }
414 logins = append(logins, login)
415 }
416 return logins, nil
417 }
419 // RegisterProfileHandlers adds handlers to the passed router to handle the profile endpoints, like registration and user retrieval.
420 func RegisterProfileHandlers(r *mux.Router, context Context) {
421 r.Handle("/profiles", wrap(context, CreateProfileHandler)).Methods("POST")
422 // BUG(paddy): We need to implement a handler that will return information about a profile or set of profiles.
423 r.Handle("/profiles/{id}", wrap(context, UpdateProfileHandler)).Methods("PATCH")
424 // 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?
425 // BUG(paddy): We need to implement a handler that will add a login to a profile.
426 // BUG(paddy): We need to implement a handler that will remove a login from a profile. What happens to sessions created with that login?
427 // BUG(paddy): We need to implement a handler that will list the logins attached to a profile.
428 }
430 // CreateProfileHandler is an HTTP handler for registering new profiles.
431 func CreateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
432 scheme, ok := passphraseSchemes[CurPassphraseScheme]
433 if !ok {
434 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
435 return
436 }
437 var req newProfileRequest
438 errors := []requestError{}
439 decoder := json.NewDecoder(r.Body)
440 err := decoder.Decode(&req)
441 if err != nil {
442 encode(w, r, http.StatusBadRequest, invalidFormatResponse)
443 return
444 }
445 errors = append(errors, validateNewProfileRequest(&req)...)
446 if len(errors) > 0 {
447 encode(w, r, http.StatusBadRequest, response{Errors: errors})
448 return
449 }
450 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations)
451 if err != nil {
452 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
453 return
454 }
455 profile := Profile{
456 ID: uuid.NewID(),
457 Name: req.Name,
458 Passphrase: string(passphrase),
459 Iterations: context.config.iterations,
460 Salt: string(salt),
461 PassphraseScheme: CurPassphraseScheme,
462 Created: time.Now(),
463 LastSeen: time.Now(),
464 }
465 err = context.SaveProfile(profile)
466 if err != nil {
467 if err == ErrProfileAlreadyExists {
468 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/id"}}})
469 return
470 }
471 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
472 return
473 }
474 logins := []Login{}
475 login := Login{
476 Type: "email",
477 Value: req.Email,
478 Created: profile.Created,
479 LastUsed: profile.Created,
480 ProfileID: profile.ID,
481 }
482 err = context.AddLogin(login)
483 if err != nil {
484 if err == ErrLoginAlreadyExists {
485 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/email"}}})
486 return
487 }
488 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
489 return
490 }
491 logins = append(logins, login)
492 if req.Username != "" {
493 login.Type = "username"
494 login.Value = req.Username
495 err = context.AddLogin(login)
496 if err != nil {
497 if err == ErrLoginAlreadyExists {
498 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/username"}}})
499 return
500 }
501 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
502 return
503 }
504 logins = append(logins, login)
505 }
506 resp := response{
507 Logins: logins,
508 Profiles: []Profile{profile},
509 }
510 encode(w, r, http.StatusCreated, resp)
511 // TODO(paddy): should we kick off the email validation flow?
512 }
514 func UpdateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
515 errors := []requestError{}
516 vars := mux.Vars(r)
517 if vars["id"] == "" {
518 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
519 encode(w, r, http.StatusBadRequest, response{Errors: errors})
520 return
521 }
522 id, err := uuid.Parse(vars["id"])
523 if err != nil {
524 errors = append(errors, requestError{Slug: requestErrAccessDenied})
525 encode(w, r, http.StatusBadRequest, response{Errors: errors})
526 return
527 }
528 username, password, ok := r.BasicAuth()
529 if !ok {
530 errors = append(errors, requestError{Slug: requestErrAccessDenied})
531 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
532 return
533 }
534 profile, err := authenticate(username, password, context)
535 if err != nil {
536 if isAuthError(err) {
537 errors = append(errors, requestError{Slug: requestErrAccessDenied})
538 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
539 } else {
540 errors = append(errors, requestError{Slug: requestErrActOfGod})
541 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
542 }
543 return
544 }
545 if !profile.ID.Equal(id) {
546 errors = append(errors, requestError{Slug: requestErrAccessDenied})
547 encode(w, r, http.StatusForbidden, response{Errors: errors})
548 return
549 }
550 var req ProfileChange
551 decoder := json.NewDecoder(r.Body)
552 err = decoder.Decode(&req)
553 if err != nil {
554 encode(w, r, http.StatusBadRequest, invalidFormatResponse)
555 return
556 }
557 req.Iterations = nil
558 req.Salt = nil
559 req.PassphraseScheme = nil
560 req.Compromised = nil // BUG(paddy): Need a way for admins to mark accounts as compromised
561 req.LockedUntil = nil
562 req.LastSeen = nil
563 if req.Passphrase != nil {
564 if len(*req.Passphrase) < MinPassphraseLength {
565 errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/passphrase"})
566 encode(w, r, http.StatusBadRequest, response{Errors: errors})
567 return
568 }
569 if len(*req.Passphrase) > MaxPassphraseLength {
570 errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/passphrase"})
571 encode(w, r, http.StatusBadRequest, response{Errors: errors})
572 return
573 }
574 iterations := context.config.iterations
575 scheme, ok := passphraseSchemes[CurPassphraseScheme]
576 if !ok {
577 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
578 return
579 }
580 curScheme := CurPassphraseScheme
581 req.PassphraseScheme = &curScheme
582 passphrase, salt, err := scheme.create(*req.Passphrase, iterations)
583 if err != nil {
584 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
585 return
586 }
587 req.Passphrase = &passphrase
588 req.Salt = &salt
589 req.Iterations = &iterations
590 }
591 if req.PassphraseReset != nil {
592 now := time.Now()
593 req.PassphraseResetCreated = &now
594 }
595 err = req.Validate()
596 if err != nil {
597 var status int
598 var resp response
599 switch err {
600 case ErrEmptyChange:
601 resp.Profiles = []Profile{profile}
602 status = http.StatusOK
603 default:
604 errors = append(errors, requestError{Slug: requestErrActOfGod})
605 resp.Errors = errors
606 status = http.StatusInternalServerError
607 }
608 encode(w, r, status, resp)
609 return
610 }
611 err = context.UpdateProfile(id, req)
612 if err != nil {
613 if err == ErrProfileNotFound {
614 errors = append(errors, requestError{Slug: requestErrNotFound})
615 encode(w, r, http.StatusNotFound, response{Errors: errors})
616 return
617 }
618 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
619 return
620 }
621 profile.ApplyChange(req)
622 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}})
623 return
624 }