auth

Paddy 2015-04-11 Parent:8267e1c8bcd1 Child:48200d8c4036

158:3223a8e679db Go to Latest

auth/profile.go

Remove concept of usernames. We really have no reason to use usernames, and they're complicating things more than they need to. We're going to keep logins the same, because we want to be able to support OAuth2/OpenID/whatever logins in the future, and keeping a type associated with those logins is probably for the best.

History
1 package auth
3 import (
4 "encoding/json"
5 "errors"
6 "log"
7 "net/http"
8 "regexp"
9 "strings"
10 "time"
12 "code.secondbit.org/uuid.hg"
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 // MaxEmailLength is the maximum length, in bytes, of an email address, exclusive.
26 MaxEmailLength = 64
27 )
29 var (
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")
65 )
67 // Profile represents a single user of the service,
68 // including their authentication information.
69 type Profile struct {
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"`
83 }
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 {
89 p.Name = *change.Name
90 }
91 if change.Passphrase != nil {
92 p.Passphrase = *change.Passphrase
93 }
94 if change.Iterations != nil {
95 p.Iterations = *change.Iterations
96 }
97 if change.Salt != nil {
98 p.Salt = *change.Salt
99 }
100 if change.PassphraseScheme != nil {
101 p.PassphraseScheme = *change.PassphraseScheme
102 }
103 if change.Compromised != nil {
104 p.Compromised = *change.Compromised
105 }
106 if change.LockedUntil != nil {
107 p.LockedUntil = *change.LockedUntil
108 }
109 if change.PassphraseReset != nil {
110 p.PassphraseReset = *change.PassphraseReset
111 }
112 if change.PassphraseResetCreated != nil {
113 p.PassphraseResetCreated = *change.PassphraseResetCreated
114 }
115 if change.LastSeen != nil {
116 p.LastSeen = *change.LastSeen
117 }
118 if change.Deleted != nil {
119 p.Deleted = *change.Deleted
120 }
121 }
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
128 }
129 }
131 // ProfileChange represents a single atomic change to a Profile's mutable data.
132 type ProfileChange struct {
133 Name *string
134 Passphrase *string
135 Iterations *int
136 Salt *string
137 PassphraseScheme *int
138 Compromised *bool
139 LockedUntil *time.Time
140 PassphraseReset *string
141 PassphraseResetCreated *time.Time
142 LastSeen *time.Time
143 Deleted *bool
144 }
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)
148 }
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 {
155 if c.Empty() {
156 return ErrEmptyChange
157 }
158 if c.PassphraseScheme != nil && c.Passphrase == nil {
159 return ErrMissingPassphrase
160 }
161 if c.PassphraseReset != nil && c.PassphraseResetCreated == nil {
162 return ErrMissingPassphraseResetCreated
163 }
164 if c.PassphraseReset == nil && c.PassphraseResetCreated != nil {
165 return ErrMissingPassphraseReset
166 }
167 if c.Salt != nil && c.Passphrase == nil {
168 return ErrMissingPassphrase
169 }
170 if c.Iterations != nil && c.Passphrase == nil {
171 return ErrMissingPassphrase
172 }
173 return nil
174 }
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 {
180 Compromised *bool
181 }
183 func (b BulkProfileChange) Empty() bool {
184 return b.Compromised == nil
185 }
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
190 // invalid change.
191 func (b BulkProfileChange) Validate() error {
192 if b.Empty() {
193 return ErrEmptyChange
194 }
195 return nil
196 }
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.
201 type Login struct {
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"`
207 }
209 type newProfileRequest struct {
210 Email string `json:"email"`
211 Passphrase string `json:"passphrase"`
212 Name string `json:"name"`
213 }
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",
223 })
224 }
225 if len(req.Passphrase) > MaxPassphraseLength {
226 errors = append(errors, requestError{
227 Slug: requestErrOverflow,
228 Field: "/passphrase",
229 })
230 }
231 if len(req.Name) > MaxNameLength {
232 errors = append(errors, requestError{
233 Slug: requestErrOverflow,
234 Field: "/name",
235 })
236 }
237 if req.Email == "" {
238 errors = append(errors, requestError{
239 Slug: requestErrMissing,
240 Field: "/email",
241 })
242 }
243 if len(req.Email) > MaxEmailLength {
244 errors = append(errors, requestError{
245 Slug: requestErrOverflow,
246 Field: "/email",
247 })
248 }
249 re := regexp.MustCompile(".+@.+\\..+")
250 if !re.Match([]byte(req.Email)) {
251 errors = append(errors, requestError{
252 Slug: requestErrInvalidFormat,
253 Field: "/email",
254 })
255 }
256 return errors
257 }
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)
270 }
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()]
276 if !ok {
277 return Profile{}, ErrProfileNotFound
278 }
279 if p.Deleted {
280 return Profile{}, ErrProfileNotFound
281 }
282 return p, nil
283 }
285 func (m *memstore) getProfileByLogin(value string) (Profile, error) {
286 m.loginLock.RLock()
287 defer m.loginLock.RUnlock()
288 login, ok := m.logins[value]
289 if !ok {
290 return Profile{}, ErrLoginNotFound
291 }
292 m.profileLock.RLock()
293 defer m.profileLock.RUnlock()
294 profile, ok := m.profiles[login.ProfileID.String()]
295 if !ok {
296 return Profile{}, ErrProfileNotFound
297 }
298 if profile.Deleted {
299 return Profile{}, ErrProfileNotFound
300 }
301 return profile, nil
302 }
304 func (m *memstore) saveProfile(profile Profile) error {
305 m.profileLock.Lock()
306 defer m.profileLock.Unlock()
307 _, ok := m.profiles[profile.ID.String()]
308 if ok {
309 return ErrProfileAlreadyExists
310 }
311 m.profiles[profile.ID.String()] = profile
312 return nil
313 }
315 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error {
316 m.profileLock.Lock()
317 defer m.profileLock.Unlock()
318 p, ok := m.profiles[id.String()]
319 if !ok {
320 return ErrProfileNotFound
321 }
322 p.ApplyChange(change)
323 m.profiles[id.String()] = p
324 return nil
325 }
327 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error {
328 m.profileLock.Lock()
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
335 break
336 }
337 }
338 }
339 return nil
340 }
342 func (m *memstore) addLogin(login Login) error {
343 m.loginLock.Lock()
344 defer m.loginLock.Unlock()
345 _, ok := m.logins[login.Value]
346 if ok {
347 return ErrLoginAlreadyExists
348 }
349 m.logins[login.Value] = login
350 m.profileLoginLookup[login.ProfileID.String()] = append(m.profileLoginLookup[login.ProfileID.String()], login.Value)
351 return nil
352 }
354 func (m *memstore) removeLogin(value string, profile uuid.ID) error {
355 m.loginLock.Lock()
356 defer m.loginLock.Unlock()
357 l, ok := m.logins[value]
358 if !ok {
359 return ErrLoginNotFound
360 }
361 if !l.ProfileID.Equal(profile) {
362 return ErrLoginNotFound
363 }
364 delete(m.logins, value)
365 pos := -1
366 for p, id := range m.profileLoginLookup[profile.String()] {
367 if id == value {
368 pos = p
369 break
370 }
371 }
372 if pos >= 0 {
373 m.profileLoginLookup[profile.String()] = append(m.profileLoginLookup[profile.String()][:pos], m.profileLoginLookup[profile.String()][pos+1:]...)
374 }
375 return nil
376 }
378 func (m *memstore) recordLoginUse(value string, when time.Time) error {
379 m.loginLock.Lock()
380 defer m.loginLock.Unlock()
381 l, ok := m.logins[value]
382 if !ok {
383 return ErrLoginNotFound
384 }
385 l.LastUsed = when
386 m.logins[value] = l
387 return nil
388 }
390 func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) {
391 m.loginLock.RLock()
392 defer m.loginLock.RUnlock()
393 ids, ok := m.profileLoginLookup[profile.String()]
394 if !ok {
395 return []Login{}, nil
396 }
397 if len(ids) > num+offset {
398 ids = ids[offset : num+offset]
399 } else if len(ids) > offset {
400 ids = ids[offset:]
401 } else {
402 return []Login{}, nil
403 }
404 logins := []Login{}
405 for _, id := range ids {
406 login, ok := m.logins[id]
407 if !ok {
408 continue
409 }
410 logins = append(logins, login)
411 }
412 return logins, nil
413 }
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.
424 }
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]
429 if !ok {
430 log.Printf("Error selecting passphrase scheme #%d\n", CurPassphraseScheme)
431 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
432 return
433 }
434 var req newProfileRequest
435 errors := []requestError{}
436 decoder := json.NewDecoder(r.Body)
437 err := decoder.Decode(&req)
438 if err != nil {
439 encode(w, r, http.StatusBadRequest, invalidFormatResponse)
440 return
441 }
442 errors = append(errors, validateNewProfileRequest(&req)...)
443 if len(errors) > 0 {
444 encode(w, r, http.StatusBadRequest, response{Errors: errors})
445 return
446 }
447 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations)
448 if err != nil {
449 log.Printf("Error creating encoded passphrase: %#+v\n", err)
450 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
451 return
452 }
453 profile := Profile{
454 ID: uuid.NewID(),
455 Name: req.Name,
456 Passphrase: string(passphrase),
457 Iterations: context.config.iterations,
458 Salt: string(salt),
459 PassphraseScheme: CurPassphraseScheme,
460 Created: time.Now(),
461 LastSeen: time.Now(),
462 }
463 err = context.SaveProfile(profile)
464 if err != nil {
465 if err == ErrProfileAlreadyExists {
466 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/id"}}})
467 return
468 }
469 log.Printf("Error saving profile: %#+v\n", err)
470 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
471 return
472 }
473 logins := []Login{}
474 login := Login{
475 Type: "email",
476 Value: req.Email,
477 Created: profile.Created,
478 LastUsed: profile.Created,
479 ProfileID: profile.ID,
480 }
481 err = context.AddLogin(login)
482 if err != nil {
483 if err == ErrLoginAlreadyExists {
484 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/email"}}})
485 return
486 }
487 log.Printf("Error adding login: %#+v\n", err)
488 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
489 return
490 }
491 logins = append(logins, login)
492 resp := response{
493 Logins: logins,
494 Profiles: []Profile{profile},
495 }
496 encode(w, r, http.StatusCreated, resp)
497 // TODO(paddy): should we kick off the email validation flow?
498 }
500 func UpdateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
501 errors := []requestError{}
502 vars := mux.Vars(r)
503 if vars["id"] == "" {
504 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"})
505 encode(w, r, http.StatusBadRequest, response{Errors: errors})
506 return
507 }
508 id, err := uuid.Parse(vars["id"])
509 if err != nil {
510 errors = append(errors, requestError{Slug: requestErrAccessDenied})
511 encode(w, r, http.StatusBadRequest, response{Errors: errors})
512 return
513 }
514 username, password, ok := r.BasicAuth()
515 if !ok {
516 errors = append(errors, requestError{Slug: requestErrAccessDenied})
517 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
518 return
519 }
520 profile, err := authenticate(username, password, context)
521 if err != nil {
522 if isAuthError(err) {
523 errors = append(errors, requestError{Slug: requestErrAccessDenied})
524 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
525 } else {
526 errors = append(errors, requestError{Slug: requestErrActOfGod})
527 encode(w, r, http.StatusInternalServerError, response{Errors: errors})
528 }
529 return
530 }
531 if !profile.ID.Equal(id) {
532 errors = append(errors, requestError{Slug: requestErrAccessDenied})
533 encode(w, r, http.StatusForbidden, response{Errors: errors})
534 return
535 }
536 var req ProfileChange
537 decoder := json.NewDecoder(r.Body)
538 err = decoder.Decode(&req)
539 if err != nil {
540 log.Printf("Error decoding request: %#+v\n", err)
541 encode(w, r, http.StatusBadRequest, invalidFormatResponse)
542 return
543 }
544 req.Iterations = nil
545 req.Salt = nil
546 req.PassphraseScheme = nil
547 req.Compromised = nil // BUG(paddy): Need a way for admins to mark accounts as compromised
548 req.LockedUntil = nil
549 req.LastSeen = 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})
554 return
555 }
556 if len(*req.Passphrase) > MaxPassphraseLength {
557 errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/passphrase"})
558 encode(w, r, http.StatusBadRequest, response{Errors: errors})
559 return
560 }
561 iterations := context.config.iterations
562 scheme, ok := passphraseSchemes[CurPassphraseScheme]
563 if !ok {
564 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
565 return
566 }
567 curScheme := CurPassphraseScheme
568 req.PassphraseScheme = &curScheme
569 passphrase, salt, err := scheme.create(*req.Passphrase, iterations)
570 if err != nil {
571 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
572 return
573 }
574 req.Passphrase = &passphrase
575 req.Salt = &salt
576 req.Iterations = &iterations
577 }
578 if req.PassphraseReset != nil {
579 now := time.Now()
580 req.PassphraseResetCreated = &now
581 }
582 err = req.Validate()
583 if err != nil {
584 var status int
585 var resp response
586 switch err {
587 case ErrEmptyChange:
588 resp.Profiles = []Profile{profile}
589 status = http.StatusOK
590 default:
591 errors = append(errors, requestError{Slug: requestErrActOfGod})
592 resp.Errors = errors
593 status = http.StatusInternalServerError
594 }
595 encode(w, r, status, resp)
596 return
597 }
598 err = context.UpdateProfile(id, req)
599 if err != nil {
600 if err == ErrProfileNotFound {
601 errors = append(errors, requestError{Slug: requestErrNotFound})
602 encode(w, r, http.StatusNotFound, response{Errors: errors})
603 return
604 }
605 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
606 return
607 }
608 profile.ApplyChange(req)
609 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}})
610 return
611 }