auth
auth/profile.go
Clean up after Client deletion, finish cleaning up after Profile deletion. 6f473576c6ae started cleaning up after Profiles when they're deleted, but didn't clean up the Clients created by that Profile. This fixes that, and also fixes a BUG note about cleaning up after a Client when it's deleted. Extend the authorizationCodeStore to have a deleteAuthorizationCodesByClientID method that will delete the AuthorizationCodes that have been granted by the Client specified by the passed ID. We also implemented this in memstore and postgres, so tests continue to pass. Extend the clientStore to have a deleteClientsByOwner method that will delete the Clients that were created by the Profile specified by the passed ID. We also implemented this in memstore and postgres, so tests continue to pass. Extend the clientStore to have a removeEndpointsByClientID method that will delete the Endpoints that belong(ed) to a the Client specified by the passed ID. We also implemented this in memstore and postgres, so tests continue to pass. Extend the tokenStore to have a revokeTokensByClientID method that will revoke all the Tokens that were granted to the Client specified by the passed ID. We also implemented this in memstore and postgres, so tests continue to pass. When listing Clients by their owner, allow setting the num argument (which controls how many to return) to 0 or lower, and using that to signal "return all Clients belonging to this owner", instead of paging. This is useful when deleting the Clients belonging to a Profile as part of the cleanup after deleting the Profile. Create a cleanUpAfterClientDeletion helper function that will delete the Endpoints and AuthorizationCodes belonging to a Client, and revoke the Tokens belonging to a Client, as part of cleaning up after a Client has been deleted. Add a check in the handler for listing Clients owned by a Profile to disallow the num argument to be lower than 1, because the API should be forced to page. Call our cleanUpAfterClientDeletion once the Client has been deleted in the appropriate handler. Fill out our Context with new methods to wrap all the new methods we're adding to our *Stores. In cleanUpAfterProfileDeletion, obtain a list of clients belonging to the owner, use our new DeleteClientsByOwner method to remove all of them, and then use the list to run our new cleanUpAfterClientDeletion function to clear away the final remnants of a Profile when it's deleted.
| paddy@27 | 1 package auth |
| paddy@27 | 2 |
| paddy@27 | 3 import ( |
| paddy@99 | 4 "encoding/json" |
| paddy@38 | 5 "errors" |
| paddy@149 | 6 "log" |
| paddy@99 | 7 "net/http" |
| paddy@99 | 8 "regexp" |
| paddy@99 | 9 "strings" |
| paddy@27 | 10 "time" |
| paddy@27 | 11 |
| paddy@107 | 12 "code.secondbit.org/uuid.hg" |
| paddy@105 | 13 "github.com/gorilla/mux" |
| paddy@27 | 14 ) |
| paddy@27 | 15 |
| paddy@48 | 16 const ( |
| paddy@57 | 17 // MinPassphraseLength is the minimum length, in bytes, of a passphrase, exclusive. |
| paddy@48 | 18 MinPassphraseLength = 6 |
| paddy@57 | 19 // MaxPassphraseLength is the maximum length, in bytes, of a passphrase, exclusive. |
| paddy@48 | 20 MaxPassphraseLength = 64 |
| paddy@69 | 21 // CurPassphraseScheme is the current passphrase scheme. Incrememnt it when we use a different passphrase scheme |
| paddy@69 | 22 CurPassphraseScheme = 1 |
| paddy@99 | 23 // MaxNameLength is the maximum length, in bytes, of a name, exclusive. |
| paddy@99 | 24 MaxNameLength = 64 |
| paddy@99 | 25 // MaxEmailLength is the maximum length, in bytes, of an email address, exclusive. |
| paddy@99 | 26 MaxEmailLength = 64 |
| paddy@48 | 27 ) |
| paddy@48 | 28 |
| paddy@38 | 29 var ( |
| paddy@57 | 30 // ErrNoProfileStore is returned when a Context tries to act on a profileStore without setting one first. |
| paddy@57 | 31 ErrNoProfileStore = errors.New("no profileStore was specified for the Context") |
| paddy@57 | 32 // ErrProfileAlreadyExists is returned when a Profile is added to a profileStore, but another Profile with |
| paddy@57 | 33 // the same ID already exists in the profileStore. |
| paddy@57 | 34 ErrProfileAlreadyExists = errors.New("profile already exists in profileStore") |
| paddy@57 | 35 // ErrProfileNotFound is returned when a Profile is requested but not found in the profileStore. |
| paddy@57 | 36 ErrProfileNotFound = errors.New("profile not found in profileStore") |
| paddy@57 | 37 // ErrLoginAlreadyExists is returned when a Login is added to a profileStore, but another Login with the same |
| paddy@57 | 38 // Type and Value already exists in the profileStore. |
| paddy@57 | 39 ErrLoginAlreadyExists = errors.New("login already exists in profileStore") |
| paddy@57 | 40 // ErrLoginNotFound is returned when a Login is requested but not found in the profileStore. |
| paddy@57 | 41 ErrLoginNotFound = errors.New("login not found in profileStore") |
| paddy@48 | 42 |
| paddy@57 | 43 // ErrMissingPassphrase is returned when a ProfileChange is validated but does not contain a |
| paddy@57 | 44 // Passphrase, and requires one. |
| paddy@57 | 45 ErrMissingPassphrase = errors.New("missing passphrase") |
| paddy@57 | 46 // ErrMissingPassphraseReset is returned when a ProfileChange is validated but does not contain |
| paddy@57 | 47 // a PassphraseReset, and requires one. |
| paddy@57 | 48 ErrMissingPassphraseReset = errors.New("missing passphrase reset") |
| paddy@57 | 49 // ErrMissingPassphraseResetCreated is returned when a ProfileChange is validated but does not |
| paddy@57 | 50 // contain a PassphraseResetCreated, and requires one. |
| paddy@48 | 51 ErrMissingPassphraseResetCreated = errors.New("missing passphrase reset created timestamp") |
| paddy@57 | 52 // ErrPassphraseTooShort is returned when a ProfileChange is validated and contains a Passphrase, |
| paddy@57 | 53 // but the Passphrase is shorter than MinPassphraseLength. |
| paddy@57 | 54 ErrPassphraseTooShort = errors.New("passphrase too short") |
| paddy@57 | 55 // ErrPassphraseTooLong is returned when a ProfileChange is validated and contains a Passphrase, |
| paddy@57 | 56 // but the Passphrase is longer than MaxPassphraseLength. |
| paddy@57 | 57 ErrPassphraseTooLong = errors.New("passphrase too long") |
| paddy@99 | 58 |
| paddy@99 | 59 // ErrProfileCompromised is returned when a user tries to log in with a profile that is suspected |
| paddy@99 | 60 // of being compromised. |
| paddy@99 | 61 ErrProfileCompromised = errors.New("profile compromised") |
| paddy@99 | 62 // ErrProfileLocked is returned when a user tries to log in with a profile that is locked for a certain |
| paddy@99 | 63 // duration, to prevent brute force attacks. |
| paddy@99 | 64 ErrProfileLocked = errors.New("profile locked") |
| paddy@38 | 65 ) |
| paddy@38 | 66 |
| paddy@57 | 67 // Profile represents a single user of the service, |
| paddy@158 | 68 // including their authentication information. |
| paddy@27 | 69 type Profile struct { |
| paddy@105 | 70 ID uuid.ID `json:"id,omitempty"` |
| paddy@105 | 71 Name string `json:"name,omitempty"` |
| paddy@105 | 72 Passphrase string `json:"-"` |
| paddy@105 | 73 Iterations int `json:"-"` |
| paddy@105 | 74 Salt string `json:"-"` |
| paddy@105 | 75 PassphraseScheme int `json:"-"` |
| paddy@105 | 76 Compromised bool `json:"-"` |
| paddy@105 | 77 LockedUntil time.Time `json:"-"` |
| paddy@105 | 78 PassphraseReset string `json:"-"` |
| paddy@105 | 79 PassphraseResetCreated time.Time `json:"-"` |
| paddy@105 | 80 Created time.Time `json:"created,omitempty"` |
| paddy@105 | 81 LastSeen time.Time `json:"last_seen,omitempty"` |
| paddy@38 | 82 } |
| paddy@38 | 83 |
| paddy@57 | 84 // ApplyChange applies the properties of the passed ProfileChange |
| paddy@57 | 85 // to the Profile it is called on. |
| paddy@38 | 86 func (p *Profile) ApplyChange(change ProfileChange) { |
| paddy@38 | 87 if change.Name != nil { |
| paddy@38 | 88 p.Name = *change.Name |
| paddy@38 | 89 } |
| paddy@38 | 90 if change.Passphrase != nil { |
| paddy@38 | 91 p.Passphrase = *change.Passphrase |
| paddy@38 | 92 } |
| paddy@38 | 93 if change.Iterations != nil { |
| paddy@38 | 94 p.Iterations = *change.Iterations |
| paddy@38 | 95 } |
| paddy@38 | 96 if change.Salt != nil { |
| paddy@38 | 97 p.Salt = *change.Salt |
| paddy@38 | 98 } |
| paddy@38 | 99 if change.PassphraseScheme != nil { |
| paddy@38 | 100 p.PassphraseScheme = *change.PassphraseScheme |
| paddy@38 | 101 } |
| paddy@38 | 102 if change.Compromised != nil { |
| paddy@38 | 103 p.Compromised = *change.Compromised |
| paddy@38 | 104 } |
| paddy@38 | 105 if change.LockedUntil != nil { |
| paddy@38 | 106 p.LockedUntil = *change.LockedUntil |
| paddy@38 | 107 } |
| paddy@38 | 108 if change.PassphraseReset != nil { |
| paddy@38 | 109 p.PassphraseReset = *change.PassphraseReset |
| paddy@38 | 110 } |
| paddy@38 | 111 if change.PassphraseResetCreated != nil { |
| paddy@38 | 112 p.PassphraseResetCreated = *change.PassphraseResetCreated |
| paddy@38 | 113 } |
| paddy@38 | 114 if change.LastSeen != nil { |
| paddy@38 | 115 p.LastSeen = *change.LastSeen |
| paddy@38 | 116 } |
| paddy@38 | 117 } |
| paddy@38 | 118 |
| paddy@57 | 119 // ApplyBulkChange applies the properties of the passed BulkProfileChange |
| paddy@57 | 120 // to the Profile it is called on. |
| paddy@44 | 121 func (p *Profile) ApplyBulkChange(change BulkProfileChange) { |
| paddy@44 | 122 if change.Compromised != nil { |
| paddy@44 | 123 p.Compromised = *change.Compromised |
| paddy@44 | 124 } |
| paddy@44 | 125 } |
| paddy@44 | 126 |
| paddy@57 | 127 // ProfileChange represents a single atomic change to a Profile's mutable data. |
| paddy@38 | 128 type ProfileChange struct { |
| paddy@38 | 129 Name *string |
| paddy@38 | 130 Passphrase *string |
| paddy@69 | 131 Iterations *int |
| paddy@38 | 132 Salt *string |
| paddy@38 | 133 PassphraseScheme *int |
| paddy@38 | 134 Compromised *bool |
| paddy@38 | 135 LockedUntil *time.Time |
| paddy@38 | 136 PassphraseReset *string |
| paddy@38 | 137 PassphraseResetCreated *time.Time |
| paddy@38 | 138 LastSeen *time.Time |
| paddy@38 | 139 } |
| paddy@38 | 140 |
| paddy@149 | 141 func (c ProfileChange) Empty() bool { |
| paddy@161 | 142 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) |
| paddy@149 | 143 } |
| paddy@149 | 144 |
| paddy@57 | 145 // Validate checks the ProfileChange it is called on |
| paddy@57 | 146 // and asserts its internal validity, or lack thereof. |
| paddy@57 | 147 // A descriptive error will be returned in the case of |
| paddy@57 | 148 // an invalid change. |
| paddy@38 | 149 func (c ProfileChange) Validate() error { |
| paddy@149 | 150 if c.Empty() { |
| paddy@48 | 151 return ErrEmptyChange |
| paddy@48 | 152 } |
| paddy@48 | 153 if c.PassphraseScheme != nil && c.Passphrase == nil { |
| paddy@48 | 154 return ErrMissingPassphrase |
| paddy@48 | 155 } |
| paddy@48 | 156 if c.PassphraseReset != nil && c.PassphraseResetCreated == nil { |
| paddy@48 | 157 return ErrMissingPassphraseResetCreated |
| paddy@48 | 158 } |
| paddy@48 | 159 if c.PassphraseReset == nil && c.PassphraseResetCreated != nil { |
| paddy@48 | 160 return ErrMissingPassphraseReset |
| paddy@48 | 161 } |
| paddy@48 | 162 if c.Salt != nil && c.Passphrase == nil { |
| paddy@48 | 163 return ErrMissingPassphrase |
| paddy@48 | 164 } |
| paddy@48 | 165 if c.Iterations != nil && c.Passphrase == nil { |
| paddy@48 | 166 return ErrMissingPassphrase |
| paddy@48 | 167 } |
| paddy@38 | 168 return nil |
| paddy@27 | 169 } |
| paddy@27 | 170 |
| paddy@57 | 171 // BulkProfileChange represents a single atomic change to many Profiles' mutable data. |
| paddy@57 | 172 // It is a subset of a ProfileChange, as it doesn't make sense to mutate some of the |
| paddy@57 | 173 // ProfileChange values across many Profiles all at once. |
| paddy@44 | 174 type BulkProfileChange struct { |
| paddy@44 | 175 Compromised *bool |
| paddy@44 | 176 } |
| paddy@44 | 177 |
| paddy@149 | 178 func (b BulkProfileChange) Empty() bool { |
| paddy@149 | 179 return b.Compromised == nil |
| paddy@149 | 180 } |
| paddy@149 | 181 |
| paddy@57 | 182 // Validate checks the BulkProfileChange it is called on |
| paddy@57 | 183 // and asserts its internal validity, or lack thereof. |
| paddy@57 | 184 // A descriptive error will be returned in the case of an |
| paddy@57 | 185 // invalid change. |
| paddy@44 | 186 func (b BulkProfileChange) Validate() error { |
| paddy@149 | 187 if b.Empty() { |
| paddy@48 | 188 return ErrEmptyChange |
| paddy@48 | 189 } |
| paddy@44 | 190 return nil |
| paddy@44 | 191 } |
| paddy@44 | 192 |
| paddy@57 | 193 // Login represents a single human-friendly identifier for |
| paddy@57 | 194 // a given Profile that can be used to log into that Profile. |
| paddy@57 | 195 // Each Profile may only have one Login for each Type. |
| paddy@27 | 196 type Login struct { |
| paddy@105 | 197 Type string `json:"type,omitempty"` |
| paddy@105 | 198 Value string `json:"value,omitempty"` |
| paddy@105 | 199 ProfileID uuid.ID `json:"profile_id,omitempty"` |
| paddy@105 | 200 Created time.Time `json:"created,omitempty"` |
| paddy@105 | 201 LastUsed time.Time `json:"last_used,omitempty"` |
| paddy@27 | 202 } |
| paddy@27 | 203 |
| paddy@99 | 204 type newProfileRequest struct { |
| paddy@99 | 205 Email string `json:"email"` |
| paddy@99 | 206 Passphrase string `json:"passphrase"` |
| paddy@99 | 207 Name string `json:"name"` |
| paddy@99 | 208 } |
| paddy@99 | 209 |
| paddy@99 | 210 func validateNewProfileRequest(req *newProfileRequest) []requestError { |
| paddy@99 | 211 errors := []requestError{} |
| paddy@99 | 212 req.Name = strings.TrimSpace(req.Name) |
| paddy@99 | 213 req.Email = strings.TrimSpace(req.Email) |
| paddy@99 | 214 if len(req.Passphrase) < MinPassphraseLength { |
| paddy@99 | 215 errors = append(errors, requestError{ |
| paddy@99 | 216 Slug: requestErrInsufficient, |
| paddy@99 | 217 Field: "/passphrase", |
| paddy@99 | 218 }) |
| paddy@99 | 219 } |
| paddy@99 | 220 if len(req.Passphrase) > MaxPassphraseLength { |
| paddy@99 | 221 errors = append(errors, requestError{ |
| paddy@99 | 222 Slug: requestErrOverflow, |
| paddy@99 | 223 Field: "/passphrase", |
| paddy@99 | 224 }) |
| paddy@99 | 225 } |
| paddy@99 | 226 if len(req.Name) > MaxNameLength { |
| paddy@99 | 227 errors = append(errors, requestError{ |
| paddy@99 | 228 Slug: requestErrOverflow, |
| paddy@99 | 229 Field: "/name", |
| paddy@99 | 230 }) |
| paddy@99 | 231 } |
| paddy@99 | 232 if req.Email == "" { |
| paddy@99 | 233 errors = append(errors, requestError{ |
| paddy@99 | 234 Slug: requestErrMissing, |
| paddy@99 | 235 Field: "/email", |
| paddy@99 | 236 }) |
| paddy@99 | 237 } |
| paddy@99 | 238 if len(req.Email) > MaxEmailLength { |
| paddy@99 | 239 errors = append(errors, requestError{ |
| paddy@99 | 240 Slug: requestErrOverflow, |
| paddy@99 | 241 Field: "/email", |
| paddy@99 | 242 }) |
| paddy@99 | 243 } |
| paddy@99 | 244 re := regexp.MustCompile(".+@.+\\..+") |
| paddy@99 | 245 if !re.Match([]byte(req.Email)) { |
| paddy@99 | 246 errors = append(errors, requestError{ |
| paddy@105 | 247 Slug: requestErrInvalidFormat, |
| paddy@99 | 248 Field: "/email", |
| paddy@99 | 249 }) |
| paddy@99 | 250 } |
| paddy@99 | 251 return errors |
| paddy@99 | 252 } |
| paddy@99 | 253 |
| paddy@57 | 254 type profileStore interface { |
| paddy@57 | 255 getProfileByID(id uuid.ID) (Profile, error) |
| paddy@69 | 256 getProfileByLogin(value string) (Profile, error) |
| paddy@57 | 257 saveProfile(profile Profile) error |
| paddy@57 | 258 updateProfile(id uuid.ID, change ProfileChange) error |
| paddy@57 | 259 updateProfiles(ids []uuid.ID, change BulkProfileChange) error |
| paddy@161 | 260 deleteProfile(id uuid.ID) error |
| paddy@44 | 261 |
| paddy@57 | 262 addLogin(login Login) error |
| paddy@69 | 263 removeLogin(value string, profile uuid.ID) error |
| paddy@160 | 264 removeLoginsByProfile(profile uuid.ID) error |
| paddy@69 | 265 recordLoginUse(value string, when time.Time) error |
| paddy@57 | 266 listLogins(profile uuid.ID, num, offset int) ([]Login, error) |
| paddy@38 | 267 } |
| paddy@27 | 268 |
| paddy@57 | 269 func (m *memstore) getProfileByID(id uuid.ID) (Profile, error) { |
| paddy@38 | 270 m.profileLock.RLock() |
| paddy@38 | 271 defer m.profileLock.RUnlock() |
| paddy@38 | 272 p, ok := m.profiles[id.String()] |
| paddy@38 | 273 if !ok { |
| paddy@38 | 274 return Profile{}, ErrProfileNotFound |
| paddy@38 | 275 } |
| paddy@38 | 276 return p, nil |
| paddy@27 | 277 } |
| paddy@38 | 278 |
| paddy@69 | 279 func (m *memstore) getProfileByLogin(value string) (Profile, error) { |
| paddy@44 | 280 m.loginLock.RLock() |
| paddy@44 | 281 defer m.loginLock.RUnlock() |
| paddy@69 | 282 login, ok := m.logins[value] |
| paddy@44 | 283 if !ok { |
| paddy@44 | 284 return Profile{}, ErrLoginNotFound |
| paddy@44 | 285 } |
| paddy@44 | 286 m.profileLock.RLock() |
| paddy@44 | 287 defer m.profileLock.RUnlock() |
| paddy@44 | 288 profile, ok := m.profiles[login.ProfileID.String()] |
| paddy@44 | 289 if !ok { |
| paddy@44 | 290 return Profile{}, ErrProfileNotFound |
| paddy@44 | 291 } |
| paddy@44 | 292 return profile, nil |
| paddy@38 | 293 } |
| paddy@38 | 294 |
| paddy@57 | 295 func (m *memstore) saveProfile(profile Profile) error { |
| paddy@38 | 296 m.profileLock.Lock() |
| paddy@38 | 297 defer m.profileLock.Unlock() |
| paddy@38 | 298 _, ok := m.profiles[profile.ID.String()] |
| paddy@38 | 299 if ok { |
| paddy@38 | 300 return ErrProfileAlreadyExists |
| paddy@38 | 301 } |
| paddy@38 | 302 m.profiles[profile.ID.String()] = profile |
| paddy@38 | 303 return nil |
| paddy@38 | 304 } |
| paddy@38 | 305 |
| paddy@57 | 306 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error { |
| paddy@38 | 307 m.profileLock.Lock() |
| paddy@38 | 308 defer m.profileLock.Unlock() |
| paddy@38 | 309 p, ok := m.profiles[id.String()] |
| paddy@38 | 310 if !ok { |
| paddy@38 | 311 return ErrProfileNotFound |
| paddy@38 | 312 } |
| paddy@38 | 313 p.ApplyChange(change) |
| paddy@38 | 314 m.profiles[id.String()] = p |
| paddy@38 | 315 return nil |
| paddy@38 | 316 } |
| paddy@38 | 317 |
| paddy@57 | 318 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error { |
| paddy@44 | 319 m.profileLock.Lock() |
| paddy@44 | 320 defer m.profileLock.Unlock() |
| paddy@44 | 321 for id, profile := range m.profiles { |
| paddy@44 | 322 for _, i := range ids { |
| paddy@44 | 323 if id == i.String() { |
| paddy@44 | 324 profile.ApplyBulkChange(change) |
| paddy@44 | 325 m.profiles[id] = profile |
| paddy@44 | 326 break |
| paddy@44 | 327 } |
| paddy@44 | 328 } |
| paddy@44 | 329 } |
| paddy@44 | 330 return nil |
| paddy@44 | 331 } |
| paddy@44 | 332 |
| paddy@161 | 333 func (m *memstore) deleteProfile(id uuid.ID) error { |
| paddy@161 | 334 m.profileLock.Lock() |
| paddy@161 | 335 defer m.profileLock.Unlock() |
| paddy@161 | 336 if _, ok := m.profiles[id.String()]; !ok { |
| paddy@161 | 337 return ErrProfileNotFound |
| paddy@161 | 338 } |
| paddy@161 | 339 delete(m.profiles, id.String()) |
| paddy@161 | 340 return nil |
| paddy@161 | 341 } |
| paddy@161 | 342 |
| paddy@57 | 343 func (m *memstore) addLogin(login Login) error { |
| paddy@44 | 344 m.loginLock.Lock() |
| paddy@44 | 345 defer m.loginLock.Unlock() |
| paddy@69 | 346 _, ok := m.logins[login.Value] |
| paddy@44 | 347 if ok { |
| paddy@44 | 348 return ErrLoginAlreadyExists |
| paddy@44 | 349 } |
| paddy@69 | 350 m.logins[login.Value] = login |
| paddy@69 | 351 m.profileLoginLookup[login.ProfileID.String()] = append(m.profileLoginLookup[login.ProfileID.String()], login.Value) |
| paddy@44 | 352 return nil |
| paddy@44 | 353 } |
| paddy@44 | 354 |
| paddy@69 | 355 func (m *memstore) removeLogin(value string, profile uuid.ID) error { |
| paddy@44 | 356 m.loginLock.Lock() |
| paddy@44 | 357 defer m.loginLock.Unlock() |
| paddy@69 | 358 l, ok := m.logins[value] |
| paddy@44 | 359 if !ok { |
| paddy@44 | 360 return ErrLoginNotFound |
| paddy@44 | 361 } |
| paddy@44 | 362 if !l.ProfileID.Equal(profile) { |
| paddy@44 | 363 return ErrLoginNotFound |
| paddy@44 | 364 } |
| paddy@69 | 365 delete(m.logins, value) |
| paddy@44 | 366 pos := -1 |
| paddy@44 | 367 for p, id := range m.profileLoginLookup[profile.String()] { |
| paddy@69 | 368 if id == value { |
| paddy@44 | 369 pos = p |
| paddy@44 | 370 break |
| paddy@44 | 371 } |
| paddy@44 | 372 } |
| paddy@44 | 373 if pos >= 0 { |
| paddy@44 | 374 m.profileLoginLookup[profile.String()] = append(m.profileLoginLookup[profile.String()][:pos], m.profileLoginLookup[profile.String()][pos+1:]...) |
| paddy@44 | 375 } |
| paddy@44 | 376 return nil |
| paddy@44 | 377 } |
| paddy@44 | 378 |
| paddy@160 | 379 func (m *memstore) removeLoginsByProfile(profile uuid.ID) error { |
| paddy@160 | 380 m.loginLock.Lock() |
| paddy@160 | 381 defer m.loginLock.Unlock() |
| paddy@160 | 382 logins, ok := m.profileLoginLookup[profile.String()] |
| paddy@160 | 383 if !ok { |
| paddy@160 | 384 return ErrProfileNotFound |
| paddy@160 | 385 } |
| paddy@160 | 386 delete(m.profileLoginLookup, profile.String()) |
| paddy@160 | 387 for _, login := range logins { |
| paddy@160 | 388 delete(m.logins, login) |
| paddy@160 | 389 } |
| paddy@160 | 390 return nil |
| paddy@160 | 391 } |
| paddy@160 | 392 |
| paddy@69 | 393 func (m *memstore) recordLoginUse(value string, when time.Time) error { |
| paddy@44 | 394 m.loginLock.Lock() |
| paddy@44 | 395 defer m.loginLock.Unlock() |
| paddy@69 | 396 l, ok := m.logins[value] |
| paddy@44 | 397 if !ok { |
| paddy@44 | 398 return ErrLoginNotFound |
| paddy@44 | 399 } |
| paddy@44 | 400 l.LastUsed = when |
| paddy@69 | 401 m.logins[value] = l |
| paddy@44 | 402 return nil |
| paddy@44 | 403 } |
| paddy@44 | 404 |
| paddy@57 | 405 func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) { |
| paddy@44 | 406 m.loginLock.RLock() |
| paddy@44 | 407 defer m.loginLock.RUnlock() |
| paddy@44 | 408 ids, ok := m.profileLoginLookup[profile.String()] |
| paddy@44 | 409 if !ok { |
| paddy@44 | 410 return []Login{}, nil |
| paddy@44 | 411 } |
| paddy@44 | 412 if len(ids) > num+offset { |
| paddy@44 | 413 ids = ids[offset : num+offset] |
| paddy@44 | 414 } else if len(ids) > offset { |
| paddy@44 | 415 ids = ids[offset:] |
| paddy@44 | 416 } else { |
| paddy@44 | 417 return []Login{}, nil |
| paddy@44 | 418 } |
| paddy@44 | 419 logins := []Login{} |
| paddy@44 | 420 for _, id := range ids { |
| paddy@44 | 421 login, ok := m.logins[id] |
| paddy@44 | 422 if !ok { |
| paddy@44 | 423 continue |
| paddy@44 | 424 } |
| paddy@44 | 425 logins = append(logins, login) |
| paddy@44 | 426 } |
| paddy@44 | 427 return logins, nil |
| paddy@44 | 428 } |
| paddy@99 | 429 |
| paddy@160 | 430 func cleanUpAfterProfileDeletion(profile uuid.ID, context Context) { |
| paddy@160 | 431 err := context.RemoveLoginsByProfile(profile) |
| paddy@160 | 432 if err != nil { |
| paddy@160 | 433 log.Printf("Error removing logins from profile %s: %+v\n", profile, err) |
| paddy@160 | 434 } |
| paddy@162 | 435 err = context.TerminateSessionsByProfile(profile) |
| paddy@162 | 436 if err != nil { |
| paddy@162 | 437 log.Printf("Error terminating sessions associated with profile %s: %+v\n", profile, err) |
| paddy@162 | 438 } |
| paddy@162 | 439 err = context.RevokeTokensByProfileID(profile) |
| paddy@162 | 440 if err != nil { |
| paddy@162 | 441 log.Printf("Error revoking tokens associated with profile %s: %+v\n", profile, err) |
| paddy@162 | 442 } |
| paddy@163 | 443 err = context.DeleteAuthorizationCodesByProfileID(profile) |
| paddy@163 | 444 if err != nil { |
| paddy@163 | 445 log.Printf("Error deleting authorization codes associated with profile %s: %+v\n", profile, err) |
| paddy@163 | 446 } |
| paddy@164 | 447 clients, err := context.ListClientsByOwner(profile, -1, 0) |
| paddy@164 | 448 if err != nil { |
| paddy@164 | 449 log.Printf("Error listing clients by profile %s: %+v\n", profile, err) |
| paddy@164 | 450 } |
| paddy@164 | 451 err = context.DeleteClientsByOwner(profile) |
| paddy@164 | 452 if err != nil { |
| paddy@164 | 453 log.Printf("Error deleting clients by profile %s: %+v\n", profile, err) |
| paddy@164 | 454 } |
| paddy@164 | 455 for _, client := range clients { |
| paddy@164 | 456 cleanUpAfterClientDeletion(client.ID, context) |
| paddy@164 | 457 } |
| paddy@160 | 458 } |
| paddy@160 | 459 |
| paddy@105 | 460 // RegisterProfileHandlers adds handlers to the passed router to handle the profile endpoints, like registration and user retrieval. |
| paddy@105 | 461 func RegisterProfileHandlers(r *mux.Router, context Context) { |
| paddy@105 | 462 r.Handle("/profiles", wrap(context, CreateProfileHandler)).Methods("POST") |
| paddy@128 | 463 // BUG(paddy): We need to implement a handler that will return information about a profile or set of profiles. |
| paddy@145 | 464 r.Handle("/profiles/{id}", wrap(context, UpdateProfileHandler)).Methods("PATCH") |
| paddy@160 | 465 r.Handle("/profiles/{id}", wrap(context, DeleteProfileHandler)).Methods("DELETE") |
| paddy@128 | 466 // BUG(paddy): We need to implement a handler that will add a login to a profile. |
| paddy@128 | 467 // BUG(paddy): We need to implement a handler that will remove a login from a profile. What happens to sessions created with that login? |
| paddy@128 | 468 // BUG(paddy): We need to implement a handler that will list the logins attached to a profile. |
| paddy@105 | 469 } |
| paddy@105 | 470 |
| paddy@99 | 471 // CreateProfileHandler is an HTTP handler for registering new profiles. |
| paddy@99 | 472 func CreateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@99 | 473 scheme, ok := passphraseSchemes[CurPassphraseScheme] |
| paddy@99 | 474 if !ok { |
| paddy@149 | 475 log.Printf("Error selecting passphrase scheme #%d\n", CurPassphraseScheme) |
| paddy@105 | 476 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 477 return |
| paddy@99 | 478 } |
| paddy@99 | 479 var req newProfileRequest |
| paddy@99 | 480 errors := []requestError{} |
| paddy@99 | 481 decoder := json.NewDecoder(r.Body) |
| paddy@99 | 482 err := decoder.Decode(&req) |
| paddy@99 | 483 if err != nil { |
| paddy@105 | 484 encode(w, r, http.StatusBadRequest, invalidFormatResponse) |
| paddy@99 | 485 return |
| paddy@99 | 486 } |
| paddy@99 | 487 errors = append(errors, validateNewProfileRequest(&req)...) |
| paddy@99 | 488 if len(errors) > 0 { |
| paddy@105 | 489 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@99 | 490 return |
| paddy@99 | 491 } |
| paddy@99 | 492 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations) |
| paddy@99 | 493 if err != nil { |
| paddy@149 | 494 log.Printf("Error creating encoded passphrase: %#+v\n", err) |
| paddy@105 | 495 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 496 return |
| paddy@99 | 497 } |
| paddy@99 | 498 profile := Profile{ |
| paddy@99 | 499 ID: uuid.NewID(), |
| paddy@99 | 500 Name: req.Name, |
| paddy@99 | 501 Passphrase: string(passphrase), |
| paddy@99 | 502 Iterations: context.config.iterations, |
| paddy@99 | 503 Salt: string(salt), |
| paddy@99 | 504 PassphraseScheme: CurPassphraseScheme, |
| paddy@99 | 505 Created: time.Now(), |
| paddy@99 | 506 LastSeen: time.Now(), |
| paddy@99 | 507 } |
| paddy@99 | 508 err = context.SaveProfile(profile) |
| paddy@99 | 509 if err != nil { |
| paddy@105 | 510 if err == ErrProfileAlreadyExists { |
| paddy@105 | 511 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/id"}}}) |
| paddy@105 | 512 return |
| paddy@105 | 513 } |
| paddy@149 | 514 log.Printf("Error saving profile: %#+v\n", err) |
| paddy@105 | 515 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 516 return |
| paddy@99 | 517 } |
| paddy@99 | 518 logins := []Login{} |
| paddy@99 | 519 login := Login{ |
| paddy@99 | 520 Type: "email", |
| paddy@99 | 521 Value: req.Email, |
| paddy@99 | 522 Created: profile.Created, |
| paddy@99 | 523 LastUsed: profile.Created, |
| paddy@99 | 524 ProfileID: profile.ID, |
| paddy@99 | 525 } |
| paddy@99 | 526 err = context.AddLogin(login) |
| paddy@99 | 527 if err != nil { |
| paddy@105 | 528 if err == ErrLoginAlreadyExists { |
| paddy@105 | 529 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/email"}}}) |
| paddy@105 | 530 return |
| paddy@105 | 531 } |
| paddy@149 | 532 log.Printf("Error adding login: %#+v\n", err) |
| paddy@105 | 533 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 534 return |
| paddy@99 | 535 } |
| paddy@99 | 536 logins = append(logins, login) |
| paddy@105 | 537 resp := response{ |
| paddy@105 | 538 Logins: logins, |
| paddy@105 | 539 Profiles: []Profile{profile}, |
| paddy@105 | 540 } |
| paddy@105 | 541 encode(w, r, http.StatusCreated, resp) |
| paddy@99 | 542 // TODO(paddy): should we kick off the email validation flow? |
| paddy@99 | 543 } |
| paddy@145 | 544 |
| paddy@145 | 545 func UpdateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@145 | 546 errors := []requestError{} |
| paddy@145 | 547 vars := mux.Vars(r) |
| paddy@145 | 548 if vars["id"] == "" { |
| paddy@145 | 549 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"}) |
| paddy@145 | 550 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 551 return |
| paddy@145 | 552 } |
| paddy@145 | 553 id, err := uuid.Parse(vars["id"]) |
| paddy@145 | 554 if err != nil { |
| paddy@145 | 555 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 556 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 557 return |
| paddy@145 | 558 } |
| paddy@145 | 559 username, password, ok := r.BasicAuth() |
| paddy@145 | 560 if !ok { |
| paddy@145 | 561 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 562 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@145 | 563 return |
| paddy@145 | 564 } |
| paddy@145 | 565 profile, err := authenticate(username, password, context) |
| paddy@145 | 566 if err != nil { |
| paddy@145 | 567 if isAuthError(err) { |
| paddy@145 | 568 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 569 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@145 | 570 } else { |
| paddy@145 | 571 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@145 | 572 encode(w, r, http.StatusInternalServerError, response{Errors: errors}) |
| paddy@145 | 573 } |
| paddy@145 | 574 return |
| paddy@145 | 575 } |
| paddy@145 | 576 if !profile.ID.Equal(id) { |
| paddy@145 | 577 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 578 encode(w, r, http.StatusForbidden, response{Errors: errors}) |
| paddy@145 | 579 return |
| paddy@145 | 580 } |
| paddy@145 | 581 var req ProfileChange |
| paddy@145 | 582 decoder := json.NewDecoder(r.Body) |
| paddy@145 | 583 err = decoder.Decode(&req) |
| paddy@145 | 584 if err != nil { |
| paddy@149 | 585 log.Printf("Error decoding request: %#+v\n", err) |
| paddy@145 | 586 encode(w, r, http.StatusBadRequest, invalidFormatResponse) |
| paddy@145 | 587 return |
| paddy@145 | 588 } |
| paddy@145 | 589 req.Iterations = nil |
| paddy@145 | 590 req.Salt = nil |
| paddy@145 | 591 req.PassphraseScheme = nil |
| paddy@145 | 592 req.Compromised = nil // BUG(paddy): Need a way for admins to mark accounts as compromised |
| paddy@145 | 593 req.LockedUntil = nil |
| paddy@145 | 594 req.LastSeen = nil |
| paddy@145 | 595 if req.Passphrase != nil { |
| paddy@145 | 596 if len(*req.Passphrase) < MinPassphraseLength { |
| paddy@145 | 597 errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/passphrase"}) |
| paddy@145 | 598 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 599 return |
| paddy@145 | 600 } |
| paddy@145 | 601 if len(*req.Passphrase) > MaxPassphraseLength { |
| paddy@145 | 602 errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/passphrase"}) |
| paddy@145 | 603 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 604 return |
| paddy@145 | 605 } |
| paddy@145 | 606 iterations := context.config.iterations |
| paddy@145 | 607 scheme, ok := passphraseSchemes[CurPassphraseScheme] |
| paddy@145 | 608 if !ok { |
| paddy@145 | 609 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 610 return |
| paddy@145 | 611 } |
| paddy@145 | 612 curScheme := CurPassphraseScheme |
| paddy@145 | 613 req.PassphraseScheme = &curScheme |
| paddy@145 | 614 passphrase, salt, err := scheme.create(*req.Passphrase, iterations) |
| paddy@145 | 615 if err != nil { |
| paddy@145 | 616 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 617 return |
| paddy@145 | 618 } |
| paddy@145 | 619 req.Passphrase = &passphrase |
| paddy@145 | 620 req.Salt = &salt |
| paddy@145 | 621 req.Iterations = &iterations |
| paddy@145 | 622 } |
| paddy@145 | 623 if req.PassphraseReset != nil { |
| paddy@145 | 624 now := time.Now() |
| paddy@145 | 625 req.PassphraseResetCreated = &now |
| paddy@145 | 626 } |
| paddy@145 | 627 err = req.Validate() |
| paddy@145 | 628 if err != nil { |
| paddy@145 | 629 var status int |
| paddy@145 | 630 var resp response |
| paddy@145 | 631 switch err { |
| paddy@145 | 632 case ErrEmptyChange: |
| paddy@145 | 633 resp.Profiles = []Profile{profile} |
| paddy@145 | 634 status = http.StatusOK |
| paddy@145 | 635 default: |
| paddy@145 | 636 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@145 | 637 resp.Errors = errors |
| paddy@145 | 638 status = http.StatusInternalServerError |
| paddy@145 | 639 } |
| paddy@145 | 640 encode(w, r, status, resp) |
| paddy@145 | 641 return |
| paddy@145 | 642 } |
| paddy@145 | 643 err = context.UpdateProfile(id, req) |
| paddy@145 | 644 if err != nil { |
| paddy@145 | 645 if err == ErrProfileNotFound { |
| paddy@145 | 646 errors = append(errors, requestError{Slug: requestErrNotFound}) |
| paddy@145 | 647 encode(w, r, http.StatusNotFound, response{Errors: errors}) |
| paddy@145 | 648 return |
| paddy@145 | 649 } |
| paddy@145 | 650 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 651 return |
| paddy@145 | 652 } |
| paddy@145 | 653 profile.ApplyChange(req) |
| paddy@145 | 654 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}}) |
| paddy@145 | 655 return |
| paddy@145 | 656 } |
| paddy@160 | 657 |
| paddy@160 | 658 func DeleteProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@160 | 659 errors := []requestError{} |
| paddy@160 | 660 vars := mux.Vars(r) |
| paddy@160 | 661 if vars["id"] == "" { |
| paddy@160 | 662 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"}) |
| paddy@160 | 663 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@160 | 664 return |
| paddy@160 | 665 } |
| paddy@160 | 666 id, err := uuid.Parse(vars["id"]) |
| paddy@160 | 667 if err != nil { |
| paddy@160 | 668 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@160 | 669 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@160 | 670 return |
| paddy@160 | 671 } |
| paddy@160 | 672 username, password, ok := r.BasicAuth() |
| paddy@160 | 673 if !ok { |
| paddy@160 | 674 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@160 | 675 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@160 | 676 return |
| paddy@160 | 677 } |
| paddy@160 | 678 profile, err := authenticate(username, password, context) |
| paddy@160 | 679 if err != nil { |
| paddy@160 | 680 if isAuthError(err) { |
| paddy@160 | 681 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@160 | 682 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@160 | 683 } else { |
| paddy@160 | 684 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@160 | 685 encode(w, r, http.StatusInternalServerError, response{Errors: errors}) |
| paddy@160 | 686 } |
| paddy@160 | 687 return |
| paddy@160 | 688 } |
| paddy@160 | 689 if !profile.ID.Equal(id) { |
| paddy@160 | 690 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@160 | 691 encode(w, r, http.StatusForbidden, response{Errors: errors}) |
| paddy@160 | 692 return |
| paddy@160 | 693 } |
| paddy@161 | 694 err = context.DeleteProfile(id) |
| paddy@160 | 695 if err != nil { |
| paddy@160 | 696 if err == ErrProfileNotFound { |
| paddy@160 | 697 errors = append(errors, requestError{Slug: requestErrNotFound}) |
| paddy@160 | 698 encode(w, r, http.StatusNotFound, response{Errors: errors}) |
| paddy@160 | 699 return |
| paddy@160 | 700 } |
| paddy@160 | 701 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@160 | 702 return |
| paddy@160 | 703 } |
| paddy@160 | 704 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}}) |
| paddy@160 | 705 go cleanUpAfterProfileDeletion(profile.ID, context) |
| paddy@160 | 706 } |