auth
auth/profile.go
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.
| 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@148 | 82 Deleted bool `json:"deleted,omitempty"` |
| paddy@38 | 83 } |
| paddy@38 | 84 |
| paddy@57 | 85 // ApplyChange applies the properties of the passed ProfileChange |
| paddy@57 | 86 // to the Profile it is called on. |
| paddy@38 | 87 func (p *Profile) ApplyChange(change ProfileChange) { |
| paddy@38 | 88 if change.Name != nil { |
| paddy@38 | 89 p.Name = *change.Name |
| paddy@38 | 90 } |
| paddy@38 | 91 if change.Passphrase != nil { |
| paddy@38 | 92 p.Passphrase = *change.Passphrase |
| paddy@38 | 93 } |
| paddy@38 | 94 if change.Iterations != nil { |
| paddy@38 | 95 p.Iterations = *change.Iterations |
| paddy@38 | 96 } |
| paddy@38 | 97 if change.Salt != nil { |
| paddy@38 | 98 p.Salt = *change.Salt |
| paddy@38 | 99 } |
| paddy@38 | 100 if change.PassphraseScheme != nil { |
| paddy@38 | 101 p.PassphraseScheme = *change.PassphraseScheme |
| paddy@38 | 102 } |
| paddy@38 | 103 if change.Compromised != nil { |
| paddy@38 | 104 p.Compromised = *change.Compromised |
| paddy@38 | 105 } |
| paddy@38 | 106 if change.LockedUntil != nil { |
| paddy@38 | 107 p.LockedUntil = *change.LockedUntil |
| paddy@38 | 108 } |
| paddy@38 | 109 if change.PassphraseReset != nil { |
| paddy@38 | 110 p.PassphraseReset = *change.PassphraseReset |
| paddy@38 | 111 } |
| paddy@38 | 112 if change.PassphraseResetCreated != nil { |
| paddy@38 | 113 p.PassphraseResetCreated = *change.PassphraseResetCreated |
| paddy@38 | 114 } |
| paddy@38 | 115 if change.LastSeen != nil { |
| paddy@38 | 116 p.LastSeen = *change.LastSeen |
| paddy@38 | 117 } |
| paddy@148 | 118 if change.Deleted != nil { |
| paddy@148 | 119 p.Deleted = *change.Deleted |
| paddy@148 | 120 } |
| paddy@38 | 121 } |
| paddy@38 | 122 |
| paddy@57 | 123 // ApplyBulkChange applies the properties of the passed BulkProfileChange |
| paddy@57 | 124 // to the Profile it is called on. |
| paddy@44 | 125 func (p *Profile) ApplyBulkChange(change BulkProfileChange) { |
| paddy@44 | 126 if change.Compromised != nil { |
| paddy@44 | 127 p.Compromised = *change.Compromised |
| paddy@44 | 128 } |
| paddy@44 | 129 } |
| paddy@44 | 130 |
| paddy@57 | 131 // ProfileChange represents a single atomic change to a Profile's mutable data. |
| paddy@38 | 132 type ProfileChange struct { |
| paddy@38 | 133 Name *string |
| paddy@38 | 134 Passphrase *string |
| paddy@69 | 135 Iterations *int |
| paddy@38 | 136 Salt *string |
| paddy@38 | 137 PassphraseScheme *int |
| paddy@38 | 138 Compromised *bool |
| paddy@38 | 139 LockedUntil *time.Time |
| paddy@38 | 140 PassphraseReset *string |
| paddy@38 | 141 PassphraseResetCreated *time.Time |
| paddy@38 | 142 LastSeen *time.Time |
| paddy@148 | 143 Deleted *bool |
| paddy@38 | 144 } |
| paddy@38 | 145 |
| paddy@149 | 146 func (c ProfileChange) Empty() bool { |
| paddy@149 | 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) |
| paddy@149 | 148 } |
| paddy@149 | 149 |
| paddy@57 | 150 // Validate checks the ProfileChange it is called on |
| paddy@57 | 151 // and asserts its internal validity, or lack thereof. |
| paddy@57 | 152 // A descriptive error will be returned in the case of |
| paddy@57 | 153 // an invalid change. |
| paddy@38 | 154 func (c ProfileChange) Validate() error { |
| paddy@149 | 155 if c.Empty() { |
| paddy@48 | 156 return ErrEmptyChange |
| paddy@48 | 157 } |
| paddy@48 | 158 if c.PassphraseScheme != nil && c.Passphrase == nil { |
| paddy@48 | 159 return ErrMissingPassphrase |
| paddy@48 | 160 } |
| paddy@48 | 161 if c.PassphraseReset != nil && c.PassphraseResetCreated == nil { |
| paddy@48 | 162 return ErrMissingPassphraseResetCreated |
| paddy@48 | 163 } |
| paddy@48 | 164 if c.PassphraseReset == nil && c.PassphraseResetCreated != nil { |
| paddy@48 | 165 return ErrMissingPassphraseReset |
| paddy@48 | 166 } |
| paddy@48 | 167 if c.Salt != nil && c.Passphrase == nil { |
| paddy@48 | 168 return ErrMissingPassphrase |
| paddy@48 | 169 } |
| paddy@48 | 170 if c.Iterations != nil && c.Passphrase == nil { |
| paddy@48 | 171 return ErrMissingPassphrase |
| paddy@48 | 172 } |
| paddy@38 | 173 return nil |
| paddy@27 | 174 } |
| paddy@27 | 175 |
| paddy@57 | 176 // BulkProfileChange represents a single atomic change to many Profiles' mutable data. |
| paddy@57 | 177 // It is a subset of a ProfileChange, as it doesn't make sense to mutate some of the |
| paddy@57 | 178 // ProfileChange values across many Profiles all at once. |
| paddy@44 | 179 type BulkProfileChange struct { |
| paddy@44 | 180 Compromised *bool |
| paddy@44 | 181 } |
| paddy@44 | 182 |
| paddy@149 | 183 func (b BulkProfileChange) Empty() bool { |
| paddy@149 | 184 return b.Compromised == nil |
| paddy@149 | 185 } |
| paddy@149 | 186 |
| paddy@57 | 187 // Validate checks the BulkProfileChange it is called on |
| paddy@57 | 188 // and asserts its internal validity, or lack thereof. |
| paddy@57 | 189 // A descriptive error will be returned in the case of an |
| paddy@57 | 190 // invalid change. |
| paddy@44 | 191 func (b BulkProfileChange) Validate() error { |
| paddy@149 | 192 if b.Empty() { |
| paddy@48 | 193 return ErrEmptyChange |
| paddy@48 | 194 } |
| paddy@44 | 195 return nil |
| paddy@44 | 196 } |
| paddy@44 | 197 |
| paddy@57 | 198 // Login represents a single human-friendly identifier for |
| paddy@57 | 199 // a given Profile that can be used to log into that Profile. |
| paddy@57 | 200 // Each Profile may only have one Login for each Type. |
| paddy@27 | 201 type Login struct { |
| paddy@105 | 202 Type string `json:"type,omitempty"` |
| paddy@105 | 203 Value string `json:"value,omitempty"` |
| paddy@105 | 204 ProfileID uuid.ID `json:"profile_id,omitempty"` |
| paddy@105 | 205 Created time.Time `json:"created,omitempty"` |
| paddy@105 | 206 LastUsed time.Time `json:"last_used,omitempty"` |
| paddy@27 | 207 } |
| paddy@27 | 208 |
| paddy@99 | 209 type newProfileRequest struct { |
| paddy@99 | 210 Email string `json:"email"` |
| paddy@99 | 211 Passphrase string `json:"passphrase"` |
| paddy@99 | 212 Name string `json:"name"` |
| paddy@99 | 213 } |
| paddy@99 | 214 |
| paddy@99 | 215 func validateNewProfileRequest(req *newProfileRequest) []requestError { |
| paddy@99 | 216 errors := []requestError{} |
| paddy@99 | 217 req.Name = strings.TrimSpace(req.Name) |
| paddy@99 | 218 req.Email = strings.TrimSpace(req.Email) |
| paddy@99 | 219 if len(req.Passphrase) < MinPassphraseLength { |
| paddy@99 | 220 errors = append(errors, requestError{ |
| paddy@99 | 221 Slug: requestErrInsufficient, |
| paddy@99 | 222 Field: "/passphrase", |
| paddy@99 | 223 }) |
| paddy@99 | 224 } |
| paddy@99 | 225 if len(req.Passphrase) > MaxPassphraseLength { |
| paddy@99 | 226 errors = append(errors, requestError{ |
| paddy@99 | 227 Slug: requestErrOverflow, |
| paddy@99 | 228 Field: "/passphrase", |
| paddy@99 | 229 }) |
| paddy@99 | 230 } |
| paddy@99 | 231 if len(req.Name) > MaxNameLength { |
| paddy@99 | 232 errors = append(errors, requestError{ |
| paddy@99 | 233 Slug: requestErrOverflow, |
| paddy@99 | 234 Field: "/name", |
| paddy@99 | 235 }) |
| paddy@99 | 236 } |
| paddy@99 | 237 if req.Email == "" { |
| paddy@99 | 238 errors = append(errors, requestError{ |
| paddy@99 | 239 Slug: requestErrMissing, |
| paddy@99 | 240 Field: "/email", |
| paddy@99 | 241 }) |
| paddy@99 | 242 } |
| paddy@99 | 243 if len(req.Email) > MaxEmailLength { |
| paddy@99 | 244 errors = append(errors, requestError{ |
| paddy@99 | 245 Slug: requestErrOverflow, |
| paddy@99 | 246 Field: "/email", |
| paddy@99 | 247 }) |
| paddy@99 | 248 } |
| paddy@99 | 249 re := regexp.MustCompile(".+@.+\\..+") |
| paddy@99 | 250 if !re.Match([]byte(req.Email)) { |
| paddy@99 | 251 errors = append(errors, requestError{ |
| paddy@105 | 252 Slug: requestErrInvalidFormat, |
| paddy@99 | 253 Field: "/email", |
| paddy@99 | 254 }) |
| paddy@99 | 255 } |
| paddy@99 | 256 return errors |
| paddy@99 | 257 } |
| paddy@99 | 258 |
| paddy@57 | 259 type profileStore interface { |
| paddy@57 | 260 getProfileByID(id uuid.ID) (Profile, error) |
| paddy@69 | 261 getProfileByLogin(value string) (Profile, error) |
| paddy@57 | 262 saveProfile(profile Profile) error |
| paddy@57 | 263 updateProfile(id uuid.ID, change ProfileChange) error |
| paddy@57 | 264 updateProfiles(ids []uuid.ID, change BulkProfileChange) error |
| paddy@44 | 265 |
| paddy@57 | 266 addLogin(login Login) error |
| paddy@69 | 267 removeLogin(value string, profile uuid.ID) error |
| paddy@160 | 268 removeLoginsByProfile(profile uuid.ID) error |
| paddy@69 | 269 recordLoginUse(value string, when time.Time) error |
| paddy@57 | 270 listLogins(profile uuid.ID, num, offset int) ([]Login, error) |
| paddy@38 | 271 } |
| paddy@27 | 272 |
| paddy@57 | 273 func (m *memstore) getProfileByID(id uuid.ID) (Profile, error) { |
| paddy@38 | 274 m.profileLock.RLock() |
| paddy@38 | 275 defer m.profileLock.RUnlock() |
| paddy@38 | 276 p, ok := m.profiles[id.String()] |
| paddy@38 | 277 if !ok { |
| paddy@38 | 278 return Profile{}, ErrProfileNotFound |
| paddy@38 | 279 } |
| paddy@148 | 280 if p.Deleted { |
| paddy@148 | 281 return Profile{}, ErrProfileNotFound |
| paddy@148 | 282 } |
| paddy@38 | 283 return p, nil |
| paddy@27 | 284 } |
| paddy@38 | 285 |
| paddy@69 | 286 func (m *memstore) getProfileByLogin(value string) (Profile, error) { |
| paddy@44 | 287 m.loginLock.RLock() |
| paddy@44 | 288 defer m.loginLock.RUnlock() |
| paddy@69 | 289 login, ok := m.logins[value] |
| paddy@44 | 290 if !ok { |
| paddy@44 | 291 return Profile{}, ErrLoginNotFound |
| paddy@44 | 292 } |
| paddy@44 | 293 m.profileLock.RLock() |
| paddy@44 | 294 defer m.profileLock.RUnlock() |
| paddy@44 | 295 profile, ok := m.profiles[login.ProfileID.String()] |
| paddy@44 | 296 if !ok { |
| paddy@44 | 297 return Profile{}, ErrProfileNotFound |
| paddy@44 | 298 } |
| paddy@148 | 299 if profile.Deleted { |
| paddy@148 | 300 return Profile{}, ErrProfileNotFound |
| paddy@148 | 301 } |
| paddy@44 | 302 return profile, nil |
| paddy@38 | 303 } |
| paddy@38 | 304 |
| paddy@57 | 305 func (m *memstore) saveProfile(profile Profile) error { |
| paddy@38 | 306 m.profileLock.Lock() |
| paddy@38 | 307 defer m.profileLock.Unlock() |
| paddy@38 | 308 _, ok := m.profiles[profile.ID.String()] |
| paddy@38 | 309 if ok { |
| paddy@38 | 310 return ErrProfileAlreadyExists |
| paddy@38 | 311 } |
| paddy@38 | 312 m.profiles[profile.ID.String()] = profile |
| paddy@38 | 313 return nil |
| paddy@38 | 314 } |
| paddy@38 | 315 |
| paddy@57 | 316 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error { |
| paddy@38 | 317 m.profileLock.Lock() |
| paddy@38 | 318 defer m.profileLock.Unlock() |
| paddy@38 | 319 p, ok := m.profiles[id.String()] |
| paddy@38 | 320 if !ok { |
| paddy@38 | 321 return ErrProfileNotFound |
| paddy@38 | 322 } |
| paddy@38 | 323 p.ApplyChange(change) |
| paddy@38 | 324 m.profiles[id.String()] = p |
| paddy@38 | 325 return nil |
| paddy@38 | 326 } |
| paddy@38 | 327 |
| paddy@57 | 328 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error { |
| paddy@44 | 329 m.profileLock.Lock() |
| paddy@44 | 330 defer m.profileLock.Unlock() |
| paddy@44 | 331 for id, profile := range m.profiles { |
| paddy@44 | 332 for _, i := range ids { |
| paddy@44 | 333 if id == i.String() { |
| paddy@44 | 334 profile.ApplyBulkChange(change) |
| paddy@44 | 335 m.profiles[id] = profile |
| paddy@44 | 336 break |
| paddy@44 | 337 } |
| paddy@44 | 338 } |
| paddy@44 | 339 } |
| paddy@44 | 340 return nil |
| paddy@44 | 341 } |
| paddy@44 | 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@160 | 435 // BUG(paddy): need to terminate all sessions associated with the Profile |
| paddy@160 | 436 // BUG(paddy): need to invalidate all tokens associated with the Profile |
| paddy@160 | 437 // BUG(paddy): need to delete all the grants associated with the Profile |
| paddy@160 | 438 } |
| paddy@160 | 439 |
| paddy@105 | 440 // RegisterProfileHandlers adds handlers to the passed router to handle the profile endpoints, like registration and user retrieval. |
| paddy@105 | 441 func RegisterProfileHandlers(r *mux.Router, context Context) { |
| paddy@105 | 442 r.Handle("/profiles", wrap(context, CreateProfileHandler)).Methods("POST") |
| paddy@128 | 443 // BUG(paddy): We need to implement a handler that will return information about a profile or set of profiles. |
| paddy@145 | 444 r.Handle("/profiles/{id}", wrap(context, UpdateProfileHandler)).Methods("PATCH") |
| paddy@160 | 445 r.Handle("/profiles/{id}", wrap(context, DeleteProfileHandler)).Methods("DELETE") |
| paddy@128 | 446 // BUG(paddy): We need to implement a handler that will add a login to a profile. |
| paddy@128 | 447 // 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 | 448 // BUG(paddy): We need to implement a handler that will list the logins attached to a profile. |
| paddy@105 | 449 } |
| paddy@105 | 450 |
| paddy@99 | 451 // CreateProfileHandler is an HTTP handler for registering new profiles. |
| paddy@99 | 452 func CreateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@99 | 453 scheme, ok := passphraseSchemes[CurPassphraseScheme] |
| paddy@99 | 454 if !ok { |
| paddy@149 | 455 log.Printf("Error selecting passphrase scheme #%d\n", CurPassphraseScheme) |
| paddy@105 | 456 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 457 return |
| paddy@99 | 458 } |
| paddy@99 | 459 var req newProfileRequest |
| paddy@99 | 460 errors := []requestError{} |
| paddy@99 | 461 decoder := json.NewDecoder(r.Body) |
| paddy@99 | 462 err := decoder.Decode(&req) |
| paddy@99 | 463 if err != nil { |
| paddy@105 | 464 encode(w, r, http.StatusBadRequest, invalidFormatResponse) |
| paddy@99 | 465 return |
| paddy@99 | 466 } |
| paddy@99 | 467 errors = append(errors, validateNewProfileRequest(&req)...) |
| paddy@99 | 468 if len(errors) > 0 { |
| paddy@105 | 469 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@99 | 470 return |
| paddy@99 | 471 } |
| paddy@99 | 472 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations) |
| paddy@99 | 473 if err != nil { |
| paddy@149 | 474 log.Printf("Error creating encoded passphrase: %#+v\n", err) |
| paddy@105 | 475 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 476 return |
| paddy@99 | 477 } |
| paddy@99 | 478 profile := Profile{ |
| paddy@99 | 479 ID: uuid.NewID(), |
| paddy@99 | 480 Name: req.Name, |
| paddy@99 | 481 Passphrase: string(passphrase), |
| paddy@99 | 482 Iterations: context.config.iterations, |
| paddy@99 | 483 Salt: string(salt), |
| paddy@99 | 484 PassphraseScheme: CurPassphraseScheme, |
| paddy@99 | 485 Created: time.Now(), |
| paddy@99 | 486 LastSeen: time.Now(), |
| paddy@99 | 487 } |
| paddy@99 | 488 err = context.SaveProfile(profile) |
| paddy@99 | 489 if err != nil { |
| paddy@105 | 490 if err == ErrProfileAlreadyExists { |
| paddy@105 | 491 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/id"}}}) |
| paddy@105 | 492 return |
| paddy@105 | 493 } |
| paddy@149 | 494 log.Printf("Error saving profile: %#+v\n", err) |
| paddy@105 | 495 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 496 return |
| paddy@99 | 497 } |
| paddy@99 | 498 logins := []Login{} |
| paddy@99 | 499 login := Login{ |
| paddy@99 | 500 Type: "email", |
| paddy@99 | 501 Value: req.Email, |
| paddy@99 | 502 Created: profile.Created, |
| paddy@99 | 503 LastUsed: profile.Created, |
| paddy@99 | 504 ProfileID: profile.ID, |
| paddy@99 | 505 } |
| paddy@99 | 506 err = context.AddLogin(login) |
| paddy@99 | 507 if err != nil { |
| paddy@105 | 508 if err == ErrLoginAlreadyExists { |
| paddy@105 | 509 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/email"}}}) |
| paddy@105 | 510 return |
| paddy@105 | 511 } |
| paddy@149 | 512 log.Printf("Error adding login: %#+v\n", err) |
| paddy@105 | 513 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 514 return |
| paddy@99 | 515 } |
| paddy@99 | 516 logins = append(logins, login) |
| paddy@105 | 517 resp := response{ |
| paddy@105 | 518 Logins: logins, |
| paddy@105 | 519 Profiles: []Profile{profile}, |
| paddy@105 | 520 } |
| paddy@105 | 521 encode(w, r, http.StatusCreated, resp) |
| paddy@99 | 522 // TODO(paddy): should we kick off the email validation flow? |
| paddy@99 | 523 } |
| paddy@145 | 524 |
| paddy@145 | 525 func UpdateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@145 | 526 errors := []requestError{} |
| paddy@145 | 527 vars := mux.Vars(r) |
| paddy@145 | 528 if vars["id"] == "" { |
| paddy@145 | 529 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"}) |
| paddy@145 | 530 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 531 return |
| paddy@145 | 532 } |
| paddy@145 | 533 id, err := uuid.Parse(vars["id"]) |
| paddy@145 | 534 if err != nil { |
| paddy@145 | 535 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 536 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 537 return |
| paddy@145 | 538 } |
| paddy@145 | 539 username, password, ok := r.BasicAuth() |
| paddy@145 | 540 if !ok { |
| paddy@145 | 541 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 542 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@145 | 543 return |
| paddy@145 | 544 } |
| paddy@145 | 545 profile, err := authenticate(username, password, context) |
| paddy@145 | 546 if err != nil { |
| paddy@145 | 547 if isAuthError(err) { |
| paddy@145 | 548 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 549 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@145 | 550 } else { |
| paddy@145 | 551 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@145 | 552 encode(w, r, http.StatusInternalServerError, response{Errors: errors}) |
| paddy@145 | 553 } |
| paddy@145 | 554 return |
| paddy@145 | 555 } |
| paddy@145 | 556 if !profile.ID.Equal(id) { |
| paddy@145 | 557 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 558 encode(w, r, http.StatusForbidden, response{Errors: errors}) |
| paddy@145 | 559 return |
| paddy@145 | 560 } |
| paddy@145 | 561 var req ProfileChange |
| paddy@145 | 562 decoder := json.NewDecoder(r.Body) |
| paddy@145 | 563 err = decoder.Decode(&req) |
| paddy@145 | 564 if err != nil { |
| paddy@149 | 565 log.Printf("Error decoding request: %#+v\n", err) |
| paddy@145 | 566 encode(w, r, http.StatusBadRequest, invalidFormatResponse) |
| paddy@145 | 567 return |
| paddy@145 | 568 } |
| paddy@145 | 569 req.Iterations = nil |
| paddy@145 | 570 req.Salt = nil |
| paddy@145 | 571 req.PassphraseScheme = nil |
| paddy@145 | 572 req.Compromised = nil // BUG(paddy): Need a way for admins to mark accounts as compromised |
| paddy@145 | 573 req.LockedUntil = nil |
| paddy@145 | 574 req.LastSeen = nil |
| paddy@145 | 575 if req.Passphrase != nil { |
| paddy@145 | 576 if len(*req.Passphrase) < MinPassphraseLength { |
| paddy@145 | 577 errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/passphrase"}) |
| paddy@145 | 578 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 579 return |
| paddy@145 | 580 } |
| paddy@145 | 581 if len(*req.Passphrase) > MaxPassphraseLength { |
| paddy@145 | 582 errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/passphrase"}) |
| paddy@145 | 583 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 584 return |
| paddy@145 | 585 } |
| paddy@145 | 586 iterations := context.config.iterations |
| paddy@145 | 587 scheme, ok := passphraseSchemes[CurPassphraseScheme] |
| paddy@145 | 588 if !ok { |
| paddy@145 | 589 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 590 return |
| paddy@145 | 591 } |
| paddy@145 | 592 curScheme := CurPassphraseScheme |
| paddy@145 | 593 req.PassphraseScheme = &curScheme |
| paddy@145 | 594 passphrase, salt, err := scheme.create(*req.Passphrase, iterations) |
| paddy@145 | 595 if err != nil { |
| paddy@145 | 596 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 597 return |
| paddy@145 | 598 } |
| paddy@145 | 599 req.Passphrase = &passphrase |
| paddy@145 | 600 req.Salt = &salt |
| paddy@145 | 601 req.Iterations = &iterations |
| paddy@145 | 602 } |
| paddy@145 | 603 if req.PassphraseReset != nil { |
| paddy@145 | 604 now := time.Now() |
| paddy@145 | 605 req.PassphraseResetCreated = &now |
| paddy@145 | 606 } |
| paddy@145 | 607 err = req.Validate() |
| paddy@145 | 608 if err != nil { |
| paddy@145 | 609 var status int |
| paddy@145 | 610 var resp response |
| paddy@145 | 611 switch err { |
| paddy@145 | 612 case ErrEmptyChange: |
| paddy@145 | 613 resp.Profiles = []Profile{profile} |
| paddy@145 | 614 status = http.StatusOK |
| paddy@145 | 615 default: |
| paddy@145 | 616 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@145 | 617 resp.Errors = errors |
| paddy@145 | 618 status = http.StatusInternalServerError |
| paddy@145 | 619 } |
| paddy@145 | 620 encode(w, r, status, resp) |
| paddy@145 | 621 return |
| paddy@145 | 622 } |
| paddy@145 | 623 err = context.UpdateProfile(id, req) |
| paddy@145 | 624 if err != nil { |
| paddy@145 | 625 if err == ErrProfileNotFound { |
| paddy@145 | 626 errors = append(errors, requestError{Slug: requestErrNotFound}) |
| paddy@145 | 627 encode(w, r, http.StatusNotFound, response{Errors: errors}) |
| paddy@145 | 628 return |
| paddy@145 | 629 } |
| paddy@145 | 630 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 631 return |
| paddy@145 | 632 } |
| paddy@160 | 633 if !profile.Deleted && req.Deleted != nil && *req.Deleted { |
| paddy@160 | 634 go cleanUpAfterProfileDeletion(profile.ID, context) |
| paddy@160 | 635 } |
| paddy@145 | 636 profile.ApplyChange(req) |
| paddy@145 | 637 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}}) |
| paddy@145 | 638 return |
| paddy@145 | 639 } |
| paddy@160 | 640 |
| paddy@160 | 641 func DeleteProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@160 | 642 errors := []requestError{} |
| paddy@160 | 643 vars := mux.Vars(r) |
| paddy@160 | 644 if vars["id"] == "" { |
| paddy@160 | 645 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"}) |
| paddy@160 | 646 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@160 | 647 return |
| paddy@160 | 648 } |
| paddy@160 | 649 id, err := uuid.Parse(vars["id"]) |
| paddy@160 | 650 if err != nil { |
| paddy@160 | 651 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@160 | 652 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@160 | 653 return |
| paddy@160 | 654 } |
| paddy@160 | 655 username, password, ok := r.BasicAuth() |
| paddy@160 | 656 if !ok { |
| paddy@160 | 657 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@160 | 658 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@160 | 659 return |
| paddy@160 | 660 } |
| paddy@160 | 661 profile, err := authenticate(username, password, context) |
| paddy@160 | 662 if err != nil { |
| paddy@160 | 663 if isAuthError(err) { |
| paddy@160 | 664 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@160 | 665 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@160 | 666 } else { |
| paddy@160 | 667 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@160 | 668 encode(w, r, http.StatusInternalServerError, response{Errors: errors}) |
| paddy@160 | 669 } |
| paddy@160 | 670 return |
| paddy@160 | 671 } |
| paddy@160 | 672 if !profile.ID.Equal(id) { |
| paddy@160 | 673 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@160 | 674 encode(w, r, http.StatusForbidden, response{Errors: errors}) |
| paddy@160 | 675 return |
| paddy@160 | 676 } |
| paddy@160 | 677 var change ProfileChange |
| paddy@160 | 678 deleted := true |
| paddy@160 | 679 change.Deleted = &deleted |
| paddy@160 | 680 err = context.UpdateProfile(id, change) |
| paddy@160 | 681 if err != nil { |
| paddy@160 | 682 if err == ErrProfileNotFound { |
| paddy@160 | 683 errors = append(errors, requestError{Slug: requestErrNotFound}) |
| paddy@160 | 684 encode(w, r, http.StatusNotFound, response{Errors: errors}) |
| paddy@160 | 685 return |
| paddy@160 | 686 } |
| paddy@160 | 687 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@160 | 688 return |
| paddy@160 | 689 } |
| paddy@160 | 690 profile.ApplyChange(change) |
| paddy@160 | 691 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}}) |
| paddy@160 | 692 go cleanUpAfterProfileDeletion(profile.ID, context) |
| paddy@160 | 693 } |