Start to support deleting profiles through the API.
Create a removeLoginsByProfile method on the profileStore, to allow an easy way
to bulk-delete logins associated with a Profile after the Profile has been
deleted.
Create postgres and memstore implementations of the removeLoginsByProfile
method.
Create a cleanUpAfterProfileDeletion helper method that will clean up the child
objects of a Profile (its Sessions, Tokens, Clients, etc.). The intended usage
is to call this in a goroutine after a Profile has been deleted, to try and get
things back in order.
Detect when the UpdateProfileHandler API is used to set the Deleted flag of a
Profile to true, and clean up after the Profile when that's the case.
Add a DeleteProfileHandler API endpoint that is a shortcut to setting the
Deleted flag of a Profile to true and cleaning up after the Profile.
The problem with our approach thus far is that some of it is reversible and some
is not. If a Profile is maliciously/accidentally deleted, it's simple enough to
use the API as a superuser to restore the Profile. But doing that will not (and
cannot) restore the Logins associated with that Profile, for example. While it
would be nice to add a Deleted flag to our Logins that we could simply toggle,
that would wreak havoc with our database constraints and ensuring uniqueness of
Login values. I still don't have a solution for this, outside the superuser
manually restoring a Login for the Profile, after which the user can
authenticate themselves and add more Logins as desired. But there has to be a
better way.
I suppose since the passphrase is being stored with the Profile and not the
Login, we could offer an endpoint that would automate this, but... well, that
would be tricky. It would require the user remembering their Profile ID, and
let's be honest, nobody's going to remember a UUID.
Maybe such an endpoint would help from a customer service standpoint: we
identify their Profile manually, then send them to /profiles/ID/restorelogin or
something, and that lets them add a Login back to the Profile.
I'll figure it out later. For now, we know we at least have enough information
to identify a user is who they say they are and resolve the situation manually.
9 "code.secondbit.org/uuid.hg"
13 profileChangeName = 1 << iota
14 profileChangePassphrase
15 profileChangeIterations
17 profileChangePassphraseScheme
18 profileChangeCompromised
19 profileChangeLockedUntil
20 profileChangePassphraseReset
21 profileChangePassphraseResetCreated
26 if os.Getenv("PG_TEST_DB") != "" {
27 p, err := NewPostgres(os.Getenv("PG_TEST_DB"))
31 profileStores = append(profileStores, &p)
35 var profileStores = []profileStore{NewMemstore()}
37 func compareProfiles(profile1, profile2 Profile) (success bool, field string, val1, val2 interface{}) {
38 if !profile1.ID.Equal(profile2.ID) {
39 return false, "ID", profile1.ID, profile2.ID
41 if profile1.Name != profile2.Name {
42 return false, "name", profile1.Name, profile2.Name
44 if profile1.Passphrase != profile2.Passphrase {
45 return false, "passphrase", profile1.Passphrase, profile2.Passphrase
47 if profile1.Iterations != profile2.Iterations {
48 return false, "iterations", profile1.Iterations, profile2.Iterations
50 if profile1.Salt != profile2.Salt {
51 return false, "salt", profile1.Salt, profile2.Salt
53 if profile1.PassphraseScheme != profile2.PassphraseScheme {
54 return false, "passphrase scheme", profile1.PassphraseScheme, profile2.PassphraseScheme
56 if profile1.Compromised != profile2.Compromised {
57 return false, "compromised", profile1.Compromised, profile2.Compromised
59 if !profile1.LockedUntil.Equal(profile2.LockedUntil) {
60 return false, "locked until", profile1.LockedUntil, profile2.LockedUntil
62 if profile1.PassphraseReset != profile2.PassphraseReset {
63 return false, "passphrase reset", profile1.PassphraseReset, profile2.PassphraseReset
65 if !profile1.PassphraseResetCreated.Equal(profile2.PassphraseResetCreated) {
66 return false, "passphrase reset created", profile1.PassphraseResetCreated, profile2.PassphraseResetCreated
68 if !profile1.Created.Equal(profile2.Created) {
69 return false, "created", profile1.Created, profile2.Created
71 if !profile1.LastSeen.Equal(profile2.LastSeen) {
72 return false, "last seen", profile1.LastSeen, profile2.LastSeen
74 if profile1.Deleted != profile2.Deleted {
75 return false, "deleted", profile1.Deleted, profile2.Deleted
77 return true, "", nil, nil
80 func compareLogins(login1, login2 Login) (success bool, field string, val1, val2 interface{}) {
81 if login1.Type != login2.Type {
82 return false, "Type", login1.Type, login2.Type
84 if login1.Value != login2.Value {
85 return false, "Value", login1.Value, login2.Value
87 if !login1.ProfileID.Equal(login2.ProfileID) {
88 return false, "ProfileID", login1.ProfileID, login2.ProfileID
90 if !login1.Created.Equal(login2.Created) {
91 return false, "Created", login1.Created, login2.Created
93 if !login1.LastUsed.Equal(login2.LastUsed) {
94 return false, "LastUsed", login1.LastUsed, login2.LastUsed
96 return true, "", nil, nil
99 func TestProfileStoreSuccess(t *testing.T) {
104 Passphrase: "passphrase",
109 LockedUntil: time.Now().Add(time.Hour).Round(time.Millisecond),
110 PassphraseReset: "passphrase reset",
111 PassphraseResetCreated: time.Now().Round(time.Millisecond),
112 Created: time.Now().Round(time.Millisecond),
113 LastSeen: time.Now().Round(time.Millisecond),
115 for _, store := range profileStores {
116 context := Context{profiles: store}
117 err := context.SaveProfile(profile)
119 t.Errorf("Error saving profile to %T: %s", store, err)
121 err = context.SaveProfile(profile)
122 if err != ErrProfileAlreadyExists {
123 t.Errorf("Expected ErrProfileAlreadyExists from %T, got %+v", store, err)
125 retrieved, err := context.GetProfileByID(profile.ID)
127 t.Errorf("Error retrieving profile from %T: %s", store, err)
129 match, field, expectation, result := compareProfiles(profile, retrieved)
131 t.Errorf("Expected `%v` in the `%s` field of profile retrieved from %T, got `%v`", expectation, field, store, result)
134 err = context.UpdateProfile(profile.ID, ProfileChange{Deleted: &deleted})
136 t.Errorf("Error removing profile from %T: %s", store, err)
138 retrieved, err = context.GetProfileByID(profile.ID)
139 if err != ErrProfileNotFound {
140 t.Errorf("Expected ErrProfileNotFound from %T, got %+v and %+v", store, retrieved, err)
145 func TestProfileUpdates(t *testing.T) {
147 variations := 1 << 10
151 Passphrase: "passphrase",
156 LockedUntil: time.Now().Add(time.Hour).Round(time.Millisecond),
157 PassphraseReset: "passphrase reset",
158 PassphraseResetCreated: time.Now().Round(time.Millisecond),
159 Created: time.Now().Round(time.Millisecond),
160 LastSeen: time.Now().Round(time.Millisecond),
162 for i := 0; i < variations; i++ {
163 var name, passphrase, salt, passphraseReset string
165 var lockedUntil, passphraseResetCreated, lastSeen time.Time
166 var passphraseScheme int
169 profile.ID = uuid.NewID()
170 change := ProfileChange{}
171 expectation := profile
173 if i&profileChangeName != 0 {
174 name = fmt.Sprintf("name-%d", i)
176 expectation.Name = name
178 if i&profileChangePassphrase != 0 {
179 passphrase = fmt.Sprintf("passphrase-%d", i)
180 change.Passphrase = &passphrase
181 expectation.Passphrase = passphrase
183 if i&profileChangeIterations != 0 {
185 change.Iterations = &iterations
186 expectation.Iterations = iterations
188 if i&profileChangeSalt != 0 {
189 salt = fmt.Sprintf("salt-%d", i)
191 expectation.Salt = salt
193 if i&profileChangePassphraseScheme != 0 {
195 change.PassphraseScheme = &passphraseScheme
196 expectation.PassphraseScheme = passphraseScheme
198 if i&profileChangeCompromised != 0 {
199 compromised = i%2 != 0
200 change.Compromised = &compromised
201 expectation.Compromised = compromised
203 if i&profileChangeLockedUntil != 0 {
204 lockedUntil = time.Now().Round(time.Millisecond)
205 change.LockedUntil = &lockedUntil
206 expectation.LockedUntil = lockedUntil
208 if i&profileChangePassphraseReset != 0 {
209 passphraseReset = fmt.Sprintf("passphraseReset-%d", i)
210 change.PassphraseReset = &passphraseReset
211 expectation.PassphraseReset = passphraseReset
213 if i&profileChangePassphraseResetCreated != 0 {
214 passphraseResetCreated = time.Now().Round(time.Millisecond)
215 change.PassphraseResetCreated = &passphraseResetCreated
216 expectation.PassphraseResetCreated = passphraseResetCreated
218 if i&profileChangeLastSeen != 0 {
219 lastSeen = time.Now().Round(time.Millisecond)
220 change.LastSeen = &lastSeen
221 expectation.LastSeen = lastSeen
223 result.ApplyChange(change)
224 match, field, expected, got := compareProfiles(expectation, result)
226 t.Errorf("Expected field `%s` to be `%v`, got `%v`", field, expected, got)
228 for _, store := range profileStores {
229 context := Context{profiles: store}
230 err := context.SaveProfile(profile)
232 t.Errorf("Error saving profile in %T: %s", store, err)
234 err = context.UpdateProfile(profile.ID, change)
236 t.Errorf("Error updating profile in %T: %s", store, err)
238 retrieved, err := context.GetProfileByID(profile.ID)
240 t.Errorf("Error getting profile from %T: %s", store, err)
242 match, field, expected, got = compareProfiles(expectation, retrieved)
244 t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T", field, expected, got, store)
250 func TestProfilesUpdates(t *testing.T) {
261 change := BulkProfileChange{
264 for _, store := range profileStores {
265 context := Context{profiles: store}
266 err := context.SaveProfile(profile1)
268 t.Errorf("Error saving profile in %T: %s", store, err)
270 err = context.SaveProfile(profile2)
272 t.Errorf("Error saving profile in %T: %s", store, err)
274 err = context.SaveProfile(profile3)
276 t.Errorf("Error saving profile in %T: %s", store, err)
278 err = context.UpdateProfiles([]uuid.ID{profile1.ID, profile3.ID}, change)
280 t.Errorf("Error updating profile in %T: %s", store, err)
282 profile1.Compromised = truth
283 profile3.Compromised = truth
284 retrieved, err := context.GetProfileByID(profile1.ID)
286 t.Errorf("Error getting profile from %T: %s", store, err)
288 match, field, expected, got := compareProfiles(profile1, retrieved)
290 t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T", field, expected, got, store)
292 retrieved, err = context.GetProfileByID(profile2.ID)
294 t.Errorf("Error getting profile from %T: %s", store, err)
296 match, field, expected, got = compareProfiles(profile2, retrieved)
298 t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T", field, expected, got, store)
300 retrieved, err = context.GetProfileByID(profile3.ID)
302 t.Errorf("Error getting profile from %T: %s", store, err)
304 match, field, expected, got = compareProfiles(profile3, retrieved)
306 t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T", field, expected, got, store)
311 func TestProfileStoreLoginSuccess(t *testing.T) {
316 ProfileID: uuid.NewID(),
317 Created: time.Now().Add(-1 * time.Hour).Round(time.Millisecond),
318 LastUsed: time.Now().Add(-1 * time.Minute).Round(time.Millisecond),
320 for _, store := range profileStores {
321 context := Context{profiles: store}
322 err := context.AddLogin(login)
324 t.Errorf("Error adding login to %T: %s", store, err)
326 err = context.AddLogin(login)
327 if err != ErrLoginAlreadyExists {
328 t.Errorf("Expected ErrLoginAlreadyExists from %T, got %+v", store, err)
330 retrieved, err := context.ListLogins(login.ProfileID, 10, 0)
332 t.Errorf("Error retrieving logins from %T: %s", store, err)
334 if len(retrieved) != 1 {
335 t.Errorf("Expected 1 login result from %T, got %d", store, len(retrieved))
337 match, field, expectation, result := compareLogins(login, retrieved[0])
339 t.Errorf("Expected `%v` in the `%s` field of login retrieved from %T, got `%v`", expectation, field, store, result)
341 lastUsed := time.Now().Round(time.Millisecond)
342 err = context.RecordLoginUse(login.Value, lastUsed)
344 t.Errorf("Error recording use of login to %T: %s", store, err)
346 login.LastUsed = lastUsed
347 retrieved, err = context.ListLogins(login.ProfileID, 10, 0)
349 t.Errorf("Error retrieving logins from %T: %s", store, err)
351 if len(retrieved) != 1 {
352 t.Errorf("Expected 1 login result from %T, got %d", store, len(retrieved))
354 match, field, expectation, result = compareLogins(login, retrieved[0])
356 t.Errorf("Expected `%v` in the `%s` field of login retrieved from %T, got `%v`", expectation, field, store, result)
358 err = context.RemoveLogin(login.Value, login.ProfileID)
360 t.Errorf("Error removing login from %T: %s", store, err)
362 retrieved, err = context.ListLogins(login.ProfileID, 10, 0)
363 if len(retrieved) != 0 {
364 t.Errorf("Expected 0 login results from %T, got %d: %+v", store, len(retrieved), retrieved)
366 err = context.RemoveLogin(login.Value, login.ProfileID)
367 if err != ErrLoginNotFound {
368 t.Errorf("Expected ErrLoginNotFound from %T, got %+v", store, err)
373 func TestProfileStoreLoginRetrieval(t *testing.T) {
378 Passphrase: "passphrase",
383 LockedUntil: time.Now().Add(time.Hour).Round(time.Millisecond),
384 PassphraseReset: "passphrase reset",
385 PassphraseResetCreated: time.Now().Round(time.Millisecond),
386 Created: time.Now().Round(time.Millisecond),
387 LastSeen: time.Now().Round(time.Millisecond),
392 ProfileID: profile.ID,
393 Created: time.Now().Add(-1 * time.Hour).Round(time.Millisecond),
394 LastUsed: time.Now().Add(-1 * time.Minute).Round(time.Millisecond),
396 for _, store := range profileStores {
397 context := Context{profiles: store}
398 err := context.SaveProfile(profile)
400 t.Errorf("Error saving profile in %T: %s", store, err)
402 err = context.AddLogin(login)
404 t.Errorf("Error storing login in %T: %s", store, err)
406 retrieved, err := context.GetProfileByLogin(login.Value)
408 t.Errorf("Error retrieving profile by login from %T: %s", store, err)
410 match, field, expectation, result := compareProfiles(profile, retrieved)
412 t.Errorf("Expected `%v` in the `%s` field of profile retrieved from %T, got `%v`", expectation, field, store, result)
417 func TestProfileChangeValidation(t *testing.T) {
419 passphraseScheme := 1
420 passphraseReset := "reset"
424 enteredName := "Paddy"
425 okPassphrase := "this is a decent passphrase"
427 lockedUntil := time.Now().Round(time.Millisecond)
428 resetCreated := time.Now().Round(time.Millisecond)
429 lastSeen := time.Now().Round(time.Millisecond)
430 changes := map[*ProfileChange]error{
431 &ProfileChange{}: ErrEmptyChange,
432 &ProfileChange{PassphraseScheme: &passphraseScheme}: ErrMissingPassphrase,
433 &ProfileChange{PassphraseScheme: &passphraseScheme, Passphrase: &okPassphrase}: nil,
434 &ProfileChange{PassphraseReset: &passphraseReset}: ErrMissingPassphraseResetCreated,
435 &ProfileChange{PassphraseReset: &passphraseReset, PassphraseResetCreated: &resetCreated}: nil,
436 &ProfileChange{Salt: &salt}: ErrMissingPassphrase,
437 &ProfileChange{Salt: &salt, Passphrase: &okPassphrase}: nil,
438 &ProfileChange{Iterations: &iterations}: ErrMissingPassphrase,
439 &ProfileChange{Iterations: &iterations, Passphrase: &okPassphrase}: nil,
440 &ProfileChange{Passphrase: &okPassphrase}: nil,
441 &ProfileChange{Name: &emptyName}: nil,
442 &ProfileChange{Name: &enteredName}: nil,
443 &ProfileChange{Compromised: &compromised}: nil,
444 &ProfileChange{LockedUntil: &lockedUntil}: nil,
445 &ProfileChange{LastSeen: &lastSeen}: nil,
446 &ProfileChange{PassphraseResetCreated: &resetCreated}: ErrMissingPassphraseReset,
448 for change, expectedErr := range changes {
449 if err := change.Validate(); err != expectedErr {
450 t.Errorf("Expected %+v to give an error of %v, gave %v", change, expectedErr, err)
455 func TestBulkProfileChangeValidation(t *testing.T) {
458 changes := map[*BulkProfileChange]error{
459 &BulkProfileChange{}: ErrEmptyChange,
460 &BulkProfileChange{Compromised: &compromised}: nil,
462 for change, expectedErr := range changes {
463 if err := change.Validate(); err != expectedErr {
464 t.Errorf("Expected %+v to give an error of %v, gave %v", change, expectedErr, err)
469 // BUG(paddy): We need to test the validateNewProfileRequest helper.
470 // BUG(paddy): We need to test the CreateProfileHandler.
471 // BUG(paddy): We need to test that deleting works as we expect.