Add an endpoint to validate and register profiles.
Add a newProfileRequest object that defines the user-specified properties of a
new Profile.
Add a helper that validates a newProfileRequest and modifies it for
sanitization, mostly just removing leading and trailing whitespace.
Add MaxNameLength, MaxUsernameLength, and MaxEmailLength constants to hold the
maximum length for those properties.
Add errors to be returned when a users attempts to log in with a profile that is
compromised or locked.
Add the bare bones of a CreateProfileHandler that validates a profile
registration request adn uses it to create a Profile and at least one Login.
Create a requestError struct that is used for returning API errors, along with
constants for the slugs we'll use to signal those errors.
11 "code.secondbit.org/uuid"
13 "github.com/extemporalgenome/slug"
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 // 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.
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")
69 // Profile represents a single user of the service,
70 // including their authentication information, but not
71 // including their username or email.
81 PassphraseReset string
82 PassphraseResetCreated time.Time
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 {
93 if change.Passphrase != nil {
94 p.Passphrase = *change.Passphrase
96 if change.Iterations != nil {
97 p.Iterations = *change.Iterations
99 if change.Salt != nil {
100 p.Salt = *change.Salt
102 if change.PassphraseScheme != nil {
103 p.PassphraseScheme = *change.PassphraseScheme
105 if change.Compromised != nil {
106 p.Compromised = *change.Compromised
108 if change.LockedUntil != nil {
109 p.LockedUntil = *change.LockedUntil
111 if change.PassphraseReset != nil {
112 p.PassphraseReset = *change.PassphraseReset
114 if change.PassphraseResetCreated != nil {
115 p.PassphraseResetCreated = *change.PassphraseResetCreated
117 if change.LastSeen != nil {
118 p.LastSeen = *change.LastSeen
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
130 // ProfileChange represents a single atomic change to a Profile's mutable data.
131 type ProfileChange struct {
136 PassphraseScheme *int
138 LockedUntil *time.Time
139 PassphraseReset *string
140 PassphraseResetCreated *time.Time
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
152 if c.PassphraseScheme != nil && c.Passphrase == nil {
153 return ErrMissingPassphrase
155 if c.PassphraseReset != nil && c.PassphraseResetCreated == nil {
156 return ErrMissingPassphraseResetCreated
158 if c.PassphraseReset == nil && c.PassphraseResetCreated != nil {
159 return ErrMissingPassphraseReset
161 if c.Salt != nil && c.Passphrase == nil {
162 return ErrMissingPassphrase
164 if c.Iterations != nil && c.Passphrase == nil {
165 return ErrMissingPassphrase
167 if c.Passphrase != nil && len(*c.Passphrase) < MinPassphraseLength {
168 return ErrPassphraseTooShort
170 if c.Passphrase != nil && len(*c.Passphrase) > MaxPassphraseLength {
171 return ErrPassphraseTooLong
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 // 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
187 func (b BulkProfileChange) Validate() error {
188 if b.Compromised == nil {
189 return ErrEmptyChange
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.
205 type newProfileRequest struct {
206 Username string `json:"username"`
207 Email string `json:"email"`
208 Passphrase string `json:"passphrase"`
209 Name string `json:"name"`
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",
223 if len(req.Passphrase) > MaxPassphraseLength {
224 errors = append(errors, requestError{
225 Slug: requestErrOverflow,
226 Field: "/passphrase",
229 if len(req.Name) > MaxNameLength {
230 errors = append(errors, requestError{
231 Slug: requestErrOverflow,
235 if len(req.Username) > MaxUsernameLength {
236 errors = append(errors, requestError{
237 Slug: requestErrOverflow,
242 errors = append(errors, requestError{
243 Slug: requestErrMissing,
247 if len(req.Email) > MaxEmailLength {
248 errors = append(errors, requestError{
249 Slug: requestErrOverflow,
253 re := regexp.MustCompile(".+@.+\\..+")
254 if !re.Match([]byte(req.Email)) {
255 errors = append(errors, requestError{
256 Slug: requestErrInvalidValue,
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)
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()]
282 return Profile{}, ErrProfileNotFound
287 func (m *memstore) getProfileByLogin(value string) (Profile, error) {
289 defer m.loginLock.RUnlock()
290 login, ok := m.logins[value]
292 return Profile{}, ErrLoginNotFound
294 m.profileLock.RLock()
295 defer m.profileLock.RUnlock()
296 profile, ok := m.profiles[login.ProfileID.String()]
298 return Profile{}, ErrProfileNotFound
303 func (m *memstore) saveProfile(profile Profile) error {
305 defer m.profileLock.Unlock()
306 _, ok := m.profiles[profile.ID.String()]
308 return ErrProfileAlreadyExists
310 m.profiles[profile.ID.String()] = profile
314 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error {
316 defer m.profileLock.Unlock()
317 p, ok := m.profiles[id.String()]
319 return ErrProfileNotFound
321 p.ApplyChange(change)
322 m.profiles[id.String()] = p
326 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error {
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
341 func (m *memstore) deleteProfile(id uuid.ID) error {
343 defer m.profileLock.Unlock()
344 _, ok := m.profiles[id.String()]
346 return ErrProfileNotFound
348 delete(m.profiles, id.String())
352 func (m *memstore) addLogin(login Login) error {
354 defer m.loginLock.Unlock()
355 _, ok := m.logins[login.Value]
357 return ErrLoginAlreadyExists
359 m.logins[login.Value] = login
360 m.profileLoginLookup[login.ProfileID.String()] = append(m.profileLoginLookup[login.ProfileID.String()], login.Value)
364 func (m *memstore) removeLogin(value string, profile uuid.ID) error {
366 defer m.loginLock.Unlock()
367 l, ok := m.logins[value]
369 return ErrLoginNotFound
371 if !l.ProfileID.Equal(profile) {
372 return ErrLoginNotFound
374 delete(m.logins, value)
376 for p, id := range m.profileLoginLookup[profile.String()] {
383 m.profileLoginLookup[profile.String()] = append(m.profileLoginLookup[profile.String()][:pos], m.profileLoginLookup[profile.String()][pos+1:]...)
388 func (m *memstore) recordLoginUse(value string, when time.Time) error {
390 defer m.loginLock.Unlock()
391 l, ok := m.logins[value]
393 return ErrLoginNotFound
400 func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) {
402 defer m.loginLock.RUnlock()
403 ids, ok := m.profileLoginLookup[profile.String()]
405 return []Login{}, nil
407 if len(ids) > num+offset {
408 ids = ids[offset : num+offset]
409 } else if len(ids) > offset {
412 return []Login{}, nil
415 for _, id := range ids {
416 login, ok := m.logins[id]
420 logins = append(logins, login)
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]
429 // TODO(paddy): write error
432 var req newProfileRequest
433 errors := []requestError{}
434 decoder := json.NewDecoder(r.Body)
435 err := decoder.Decode(&req)
437 // TODO(paddy): write error
440 errors = append(errors, validateNewProfileRequest(&req)...)
442 //TODO(paddy): return errors
445 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations)
447 // TODO(paddy): write error
453 Passphrase: string(passphrase),
454 Iterations: context.config.iterations,
456 PassphraseScheme: CurPassphraseScheme,
458 LastSeen: time.Now(),
460 err = context.SaveProfile(profile)
462 // TODO(paddy): write error
469 Created: profile.Created,
470 LastUsed: profile.Created,
471 ProfileID: profile.ID,
473 err = context.AddLogin(login)
475 // TODO(paddy): write error
478 logins = append(logins, login)
479 if req.Username != "" {
480 login.Type = "username"
481 login.Value = req.Username
482 err = context.AddLogin(login)
484 // TODO(paddy): write error
487 logins = append(logins, login)
489 // TODO(paddy): respond with login(s) and profile that were created
490 // TODO(paddy): should we kick off the email validation flow?