auth
auth/profile.go
Store salts and passphrases as hex-encoded strings. Update our passphraseScheme.create function signature to return strings. Hex encode our passphrases and salts when encrypthing them so they're easier to store safely. Decode our salt before using it to check candidate passphrases.
1 package auth
3 import (
4 "encoding/json"
5 "errors"
6 "net/http"
7 "regexp"
8 "strings"
9 "time"
11 "code.secondbit.org/uuid"
13 "github.com/extemporalgenome/slug"
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
74 Name string
75 Passphrase string
76 Iterations int
77 Salt string
78 PassphraseScheme int
79 Compromised bool
80 LockedUntil time.Time
81 PassphraseReset string
82 PassphraseResetCreated time.Time
83 Created time.Time
84 LastSeen time.Time
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 if c.Passphrase != nil && len(*c.Passphrase) < MinPassphraseLength {
168 return ErrPassphraseTooShort
169 }
170 if c.Passphrase != nil && len(*c.Passphrase) > MaxPassphraseLength {
171 return ErrPassphraseTooLong
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 // Validate checks the BulkProfileChange it is called on
184 // and asserts its internal validity, or lack thereof.
185 // A descriptive error will be returned in the case of an
186 // invalid change.
187 func (b BulkProfileChange) Validate() error {
188 if b.Compromised == nil {
189 return ErrEmptyChange
190 }
191 return nil
192 }
194 // Login represents a single human-friendly identifier for
195 // a given Profile that can be used to log into that Profile.
196 // Each Profile may only have one Login for each Type.
197 type Login struct {
198 Type string
199 Value string
200 ProfileID uuid.ID
201 Created time.Time
202 LastUsed time.Time
203 }
205 type newProfileRequest struct {
206 Username string `json:"username"`
207 Email string `json:"email"`
208 Passphrase string `json:"passphrase"`
209 Name string `json:"name"`
210 }
212 func validateNewProfileRequest(req *newProfileRequest) []requestError {
213 errors := []requestError{}
214 req.Name = strings.TrimSpace(req.Name)
215 req.Email = strings.TrimSpace(req.Email)
216 req.Username = slug.SlugAscii(strings.TrimSpace(req.Username))
217 if len(req.Passphrase) < MinPassphraseLength {
218 errors = append(errors, requestError{
219 Slug: requestErrInsufficient,
220 Field: "/passphrase",
221 })
222 }
223 if len(req.Passphrase) > MaxPassphraseLength {
224 errors = append(errors, requestError{
225 Slug: requestErrOverflow,
226 Field: "/passphrase",
227 })
228 }
229 if len(req.Name) > MaxNameLength {
230 errors = append(errors, requestError{
231 Slug: requestErrOverflow,
232 Field: "/name",
233 })
234 }
235 if len(req.Username) > MaxUsernameLength {
236 errors = append(errors, requestError{
237 Slug: requestErrOverflow,
238 Field: "/username",
239 })
240 }
241 if req.Email == "" {
242 errors = append(errors, requestError{
243 Slug: requestErrMissing,
244 Field: "/email",
245 })
246 }
247 if len(req.Email) > MaxEmailLength {
248 errors = append(errors, requestError{
249 Slug: requestErrOverflow,
250 Field: "/email",
251 })
252 }
253 re := regexp.MustCompile(".+@.+\\..+")
254 if !re.Match([]byte(req.Email)) {
255 errors = append(errors, requestError{
256 Slug: requestErrInvalidValue,
257 Field: "/email",
258 })
259 }
260 return errors
261 }
263 type profileStore interface {
264 getProfileByID(id uuid.ID) (Profile, error)
265 getProfileByLogin(value string) (Profile, error)
266 saveProfile(profile Profile) error
267 updateProfile(id uuid.ID, change ProfileChange) error
268 updateProfiles(ids []uuid.ID, change BulkProfileChange) error
269 deleteProfile(id uuid.ID) error
271 addLogin(login Login) error
272 removeLogin(value string, profile uuid.ID) error
273 recordLoginUse(value string, when time.Time) error
274 listLogins(profile uuid.ID, num, offset int) ([]Login, error)
275 }
277 func (m *memstore) getProfileByID(id uuid.ID) (Profile, error) {
278 m.profileLock.RLock()
279 defer m.profileLock.RUnlock()
280 p, ok := m.profiles[id.String()]
281 if !ok {
282 return Profile{}, ErrProfileNotFound
283 }
284 return p, nil
285 }
287 func (m *memstore) getProfileByLogin(value string) (Profile, error) {
288 m.loginLock.RLock()
289 defer m.loginLock.RUnlock()
290 login, ok := m.logins[value]
291 if !ok {
292 return Profile{}, ErrLoginNotFound
293 }
294 m.profileLock.RLock()
295 defer m.profileLock.RUnlock()
296 profile, ok := m.profiles[login.ProfileID.String()]
297 if !ok {
298 return Profile{}, ErrProfileNotFound
299 }
300 return profile, nil
301 }
303 func (m *memstore) saveProfile(profile Profile) error {
304 m.profileLock.Lock()
305 defer m.profileLock.Unlock()
306 _, ok := m.profiles[profile.ID.String()]
307 if ok {
308 return ErrProfileAlreadyExists
309 }
310 m.profiles[profile.ID.String()] = profile
311 return nil
312 }
314 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error {
315 m.profileLock.Lock()
316 defer m.profileLock.Unlock()
317 p, ok := m.profiles[id.String()]
318 if !ok {
319 return ErrProfileNotFound
320 }
321 p.ApplyChange(change)
322 m.profiles[id.String()] = p
323 return nil
324 }
326 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error {
327 m.profileLock.Lock()
328 defer m.profileLock.Unlock()
329 for id, profile := range m.profiles {
330 for _, i := range ids {
331 if id == i.String() {
332 profile.ApplyBulkChange(change)
333 m.profiles[id] = profile
334 break
335 }
336 }
337 }
338 return nil
339 }
341 func (m *memstore) deleteProfile(id uuid.ID) error {
342 m.profileLock.Lock()
343 defer m.profileLock.Unlock()
344 _, ok := m.profiles[id.String()]
345 if !ok {
346 return ErrProfileNotFound
347 }
348 delete(m.profiles, id.String())
349 return nil
350 }
352 func (m *memstore) addLogin(login Login) error {
353 m.loginLock.Lock()
354 defer m.loginLock.Unlock()
355 _, ok := m.logins[login.Value]
356 if ok {
357 return ErrLoginAlreadyExists
358 }
359 m.logins[login.Value] = login
360 m.profileLoginLookup[login.ProfileID.String()] = append(m.profileLoginLookup[login.ProfileID.String()], login.Value)
361 return nil
362 }
364 func (m *memstore) removeLogin(value string, profile uuid.ID) error {
365 m.loginLock.Lock()
366 defer m.loginLock.Unlock()
367 l, ok := m.logins[value]
368 if !ok {
369 return ErrLoginNotFound
370 }
371 if !l.ProfileID.Equal(profile) {
372 return ErrLoginNotFound
373 }
374 delete(m.logins, value)
375 pos := -1
376 for p, id := range m.profileLoginLookup[profile.String()] {
377 if id == value {
378 pos = p
379 break
380 }
381 }
382 if pos >= 0 {
383 m.profileLoginLookup[profile.String()] = append(m.profileLoginLookup[profile.String()][:pos], m.profileLoginLookup[profile.String()][pos+1:]...)
384 }
385 return nil
386 }
388 func (m *memstore) recordLoginUse(value string, when time.Time) error {
389 m.loginLock.Lock()
390 defer m.loginLock.Unlock()
391 l, ok := m.logins[value]
392 if !ok {
393 return ErrLoginNotFound
394 }
395 l.LastUsed = when
396 m.logins[value] = l
397 return nil
398 }
400 func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) {
401 m.loginLock.RLock()
402 defer m.loginLock.RUnlock()
403 ids, ok := m.profileLoginLookup[profile.String()]
404 if !ok {
405 return []Login{}, nil
406 }
407 if len(ids) > num+offset {
408 ids = ids[offset : num+offset]
409 } else if len(ids) > offset {
410 ids = ids[offset:]
411 } else {
412 return []Login{}, nil
413 }
414 logins := []Login{}
415 for _, id := range ids {
416 login, ok := m.logins[id]
417 if !ok {
418 continue
419 }
420 logins = append(logins, login)
421 }
422 return logins, nil
423 }
425 // CreateProfileHandler is an HTTP handler for registering new profiles.
426 func CreateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) {
427 scheme, ok := passphraseSchemes[CurPassphraseScheme]
428 if !ok {
429 // TODO(paddy): write error
430 return
431 }
432 var req newProfileRequest
433 errors := []requestError{}
434 decoder := json.NewDecoder(r.Body)
435 err := decoder.Decode(&req)
436 if err != nil {
437 // TODO(paddy): write error
438 return
439 }
440 errors = append(errors, validateNewProfileRequest(&req)...)
441 if len(errors) > 0 {
442 //TODO(paddy): return errors
443 return
444 }
445 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations)
446 if err != nil {
447 // TODO(paddy): write error
448 return
449 }
450 profile := Profile{
451 ID: uuid.NewID(),
452 Name: req.Name,
453 Passphrase: string(passphrase),
454 Iterations: context.config.iterations,
455 Salt: string(salt),
456 PassphraseScheme: CurPassphraseScheme,
457 Created: time.Now(),
458 LastSeen: time.Now(),
459 }
460 err = context.SaveProfile(profile)
461 if err != nil {
462 // TODO(paddy): write error
463 return
464 }
465 logins := []Login{}
466 login := Login{
467 Type: "email",
468 Value: req.Email,
469 Created: profile.Created,
470 LastUsed: profile.Created,
471 ProfileID: profile.ID,
472 }
473 err = context.AddLogin(login)
474 if err != nil {
475 // TODO(paddy): write error
476 return
477 }
478 logins = append(logins, login)
479 if req.Username != "" {
480 login.Type = "username"
481 login.Value = req.Username
482 err = context.AddLogin(login)
483 if err != nil {
484 // TODO(paddy): write error
485 return
486 }
487 logins = append(logins, login)
488 }
489 // TODO(paddy): respond with login(s) and profile that were created
490 // TODO(paddy): should we kick off the email validation flow?
491 }