auth
auth/profile.go
Do a first, naive pass at storing profiles in Postgres. This is untested against an actual database. It's a best-guess attempt at SQL. It _should_ work. I think. Start storing things in Postgres, starting with Profiles and Logins. This necessitates the addition of a Deleted property to the Profile type, because I'm not deleting those in case of accidental deletion. Logins, though, we'll delete. This also necessitates updating the profileStore interface to no longer have a deleteProfile method, because we're tracking that through updates now. Then we need to update our profileStore tests, because they no longer clean up after themselves. Which, come to think of it, may cause some problems later.
| paddy@27 | 1 package auth |
| paddy@27 | 2 |
| paddy@27 | 3 import ( |
| paddy@99 | 4 "encoding/json" |
| paddy@38 | 5 "errors" |
| paddy@99 | 6 "net/http" |
| paddy@99 | 7 "regexp" |
| paddy@99 | 8 "strings" |
| paddy@27 | 9 "time" |
| paddy@27 | 10 |
| paddy@107 | 11 "code.secondbit.org/uuid.hg" |
| paddy@99 | 12 "github.com/extemporalgenome/slug" |
| 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 // MaxUsernameLength is the maximum length, in bytes, of a username, exclusive. |
| paddy@99 | 26 MaxUsernameLength = 16 |
| paddy@99 | 27 // MaxEmailLength is the maximum length, in bytes, of an email address, exclusive. |
| paddy@99 | 28 MaxEmailLength = 64 |
| paddy@48 | 29 ) |
| paddy@48 | 30 |
| paddy@38 | 31 var ( |
| paddy@57 | 32 // ErrNoProfileStore is returned when a Context tries to act on a profileStore without setting one first. |
| paddy@57 | 33 ErrNoProfileStore = errors.New("no profileStore was specified for the Context") |
| paddy@57 | 34 // ErrProfileAlreadyExists is returned when a Profile is added to a profileStore, but another Profile with |
| paddy@57 | 35 // the same ID already exists in the profileStore. |
| paddy@57 | 36 ErrProfileAlreadyExists = errors.New("profile already exists in profileStore") |
| paddy@57 | 37 // ErrProfileNotFound is returned when a Profile is requested but not found in the profileStore. |
| paddy@57 | 38 ErrProfileNotFound = errors.New("profile not found in profileStore") |
| paddy@57 | 39 // ErrLoginAlreadyExists is returned when a Login is added to a profileStore, but another Login with the same |
| paddy@57 | 40 // Type and Value already exists in the profileStore. |
| paddy@57 | 41 ErrLoginAlreadyExists = errors.New("login already exists in profileStore") |
| paddy@57 | 42 // ErrLoginNotFound is returned when a Login is requested but not found in the profileStore. |
| paddy@57 | 43 ErrLoginNotFound = errors.New("login not found in profileStore") |
| paddy@48 | 44 |
| paddy@57 | 45 // ErrMissingPassphrase is returned when a ProfileChange is validated but does not contain a |
| paddy@57 | 46 // Passphrase, and requires one. |
| paddy@57 | 47 ErrMissingPassphrase = errors.New("missing passphrase") |
| paddy@57 | 48 // ErrMissingPassphraseReset is returned when a ProfileChange is validated but does not contain |
| paddy@57 | 49 // a PassphraseReset, and requires one. |
| paddy@57 | 50 ErrMissingPassphraseReset = errors.New("missing passphrase reset") |
| paddy@57 | 51 // ErrMissingPassphraseResetCreated is returned when a ProfileChange is validated but does not |
| paddy@57 | 52 // contain a PassphraseResetCreated, and requires one. |
| paddy@48 | 53 ErrMissingPassphraseResetCreated = errors.New("missing passphrase reset created timestamp") |
| paddy@57 | 54 // ErrPassphraseTooShort is returned when a ProfileChange is validated and contains a Passphrase, |
| paddy@57 | 55 // but the Passphrase is shorter than MinPassphraseLength. |
| paddy@57 | 56 ErrPassphraseTooShort = errors.New("passphrase too short") |
| paddy@57 | 57 // ErrPassphraseTooLong is returned when a ProfileChange is validated and contains a Passphrase, |
| paddy@57 | 58 // but the Passphrase is longer than MaxPassphraseLength. |
| paddy@57 | 59 ErrPassphraseTooLong = errors.New("passphrase too long") |
| paddy@99 | 60 |
| paddy@99 | 61 // ErrProfileCompromised is returned when a user tries to log in with a profile that is suspected |
| paddy@99 | 62 // of being compromised. |
| paddy@99 | 63 ErrProfileCompromised = errors.New("profile compromised") |
| paddy@99 | 64 // ErrProfileLocked is returned when a user tries to log in with a profile that is locked for a certain |
| paddy@99 | 65 // duration, to prevent brute force attacks. |
| paddy@99 | 66 ErrProfileLocked = errors.New("profile locked") |
| paddy@38 | 67 ) |
| paddy@38 | 68 |
| paddy@57 | 69 // Profile represents a single user of the service, |
| paddy@57 | 70 // including their authentication information, but not |
| paddy@57 | 71 // including their username or email. |
| paddy@27 | 72 type Profile struct { |
| paddy@105 | 73 ID uuid.ID `json:"id,omitempty"` |
| paddy@105 | 74 Name string `json:"name,omitempty"` |
| paddy@105 | 75 Passphrase string `json:"-"` |
| paddy@105 | 76 Iterations int `json:"-"` |
| paddy@105 | 77 Salt string `json:"-"` |
| paddy@105 | 78 PassphraseScheme int `json:"-"` |
| paddy@105 | 79 Compromised bool `json:"-"` |
| paddy@105 | 80 LockedUntil time.Time `json:"-"` |
| paddy@105 | 81 PassphraseReset string `json:"-"` |
| paddy@105 | 82 PassphraseResetCreated time.Time `json:"-"` |
| paddy@105 | 83 Created time.Time `json:"created,omitempty"` |
| paddy@105 | 84 LastSeen time.Time `json:"last_seen,omitempty"` |
| paddy@148 | 85 Deleted bool `json:"deleted,omitempty"` |
| paddy@38 | 86 } |
| paddy@38 | 87 |
| paddy@57 | 88 // ApplyChange applies the properties of the passed ProfileChange |
| paddy@57 | 89 // to the Profile it is called on. |
| paddy@38 | 90 func (p *Profile) ApplyChange(change ProfileChange) { |
| paddy@38 | 91 if change.Name != nil { |
| paddy@38 | 92 p.Name = *change.Name |
| paddy@38 | 93 } |
| paddy@38 | 94 if change.Passphrase != nil { |
| paddy@38 | 95 p.Passphrase = *change.Passphrase |
| paddy@38 | 96 } |
| paddy@38 | 97 if change.Iterations != nil { |
| paddy@38 | 98 p.Iterations = *change.Iterations |
| paddy@38 | 99 } |
| paddy@38 | 100 if change.Salt != nil { |
| paddy@38 | 101 p.Salt = *change.Salt |
| paddy@38 | 102 } |
| paddy@38 | 103 if change.PassphraseScheme != nil { |
| paddy@38 | 104 p.PassphraseScheme = *change.PassphraseScheme |
| paddy@38 | 105 } |
| paddy@38 | 106 if change.Compromised != nil { |
| paddy@38 | 107 p.Compromised = *change.Compromised |
| paddy@38 | 108 } |
| paddy@38 | 109 if change.LockedUntil != nil { |
| paddy@38 | 110 p.LockedUntil = *change.LockedUntil |
| paddy@38 | 111 } |
| paddy@38 | 112 if change.PassphraseReset != nil { |
| paddy@38 | 113 p.PassphraseReset = *change.PassphraseReset |
| paddy@38 | 114 } |
| paddy@38 | 115 if change.PassphraseResetCreated != nil { |
| paddy@38 | 116 p.PassphraseResetCreated = *change.PassphraseResetCreated |
| paddy@38 | 117 } |
| paddy@38 | 118 if change.LastSeen != nil { |
| paddy@38 | 119 p.LastSeen = *change.LastSeen |
| paddy@38 | 120 } |
| paddy@148 | 121 if change.Deleted != nil { |
| paddy@148 | 122 p.Deleted = *change.Deleted |
| paddy@148 | 123 } |
| paddy@38 | 124 } |
| paddy@38 | 125 |
| paddy@57 | 126 // ApplyBulkChange applies the properties of the passed BulkProfileChange |
| paddy@57 | 127 // to the Profile it is called on. |
| paddy@44 | 128 func (p *Profile) ApplyBulkChange(change BulkProfileChange) { |
| paddy@44 | 129 if change.Compromised != nil { |
| paddy@44 | 130 p.Compromised = *change.Compromised |
| paddy@44 | 131 } |
| paddy@44 | 132 } |
| paddy@44 | 133 |
| paddy@57 | 134 // ProfileChange represents a single atomic change to a Profile's mutable data. |
| paddy@38 | 135 type ProfileChange struct { |
| paddy@38 | 136 Name *string |
| paddy@38 | 137 Passphrase *string |
| paddy@69 | 138 Iterations *int |
| paddy@38 | 139 Salt *string |
| paddy@38 | 140 PassphraseScheme *int |
| paddy@38 | 141 Compromised *bool |
| paddy@38 | 142 LockedUntil *time.Time |
| paddy@38 | 143 PassphraseReset *string |
| paddy@38 | 144 PassphraseResetCreated *time.Time |
| paddy@38 | 145 LastSeen *time.Time |
| paddy@148 | 146 Deleted *bool |
| paddy@38 | 147 } |
| paddy@38 | 148 |
| paddy@57 | 149 // Validate checks the ProfileChange it is called on |
| paddy@57 | 150 // and asserts its internal validity, or lack thereof. |
| paddy@57 | 151 // A descriptive error will be returned in the case of |
| paddy@57 | 152 // an invalid change. |
| paddy@38 | 153 func (c ProfileChange) Validate() error { |
| paddy@148 | 154 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 && c.Deleted == nil { |
| paddy@48 | 155 return ErrEmptyChange |
| paddy@48 | 156 } |
| paddy@48 | 157 if c.PassphraseScheme != nil && c.Passphrase == nil { |
| paddy@48 | 158 return ErrMissingPassphrase |
| paddy@48 | 159 } |
| paddy@48 | 160 if c.PassphraseReset != nil && c.PassphraseResetCreated == nil { |
| paddy@48 | 161 return ErrMissingPassphraseResetCreated |
| paddy@48 | 162 } |
| paddy@48 | 163 if c.PassphraseReset == nil && c.PassphraseResetCreated != nil { |
| paddy@48 | 164 return ErrMissingPassphraseReset |
| paddy@48 | 165 } |
| paddy@48 | 166 if c.Salt != nil && c.Passphrase == nil { |
| paddy@48 | 167 return ErrMissingPassphrase |
| paddy@48 | 168 } |
| paddy@48 | 169 if c.Iterations != nil && c.Passphrase == nil { |
| paddy@48 | 170 return ErrMissingPassphrase |
| paddy@48 | 171 } |
| paddy@38 | 172 return nil |
| paddy@27 | 173 } |
| paddy@27 | 174 |
| paddy@57 | 175 // BulkProfileChange represents a single atomic change to many Profiles' mutable data. |
| paddy@57 | 176 // It is a subset of a ProfileChange, as it doesn't make sense to mutate some of the |
| paddy@57 | 177 // ProfileChange values across many Profiles all at once. |
| paddy@44 | 178 type BulkProfileChange struct { |
| paddy@44 | 179 Compromised *bool |
| paddy@44 | 180 } |
| paddy@44 | 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@48 | 187 if b.Compromised == nil { |
| 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 Username string `json:"username"` |
| paddy@99 | 206 Email string `json:"email"` |
| paddy@99 | 207 Passphrase string `json:"passphrase"` |
| paddy@99 | 208 Name string `json:"name"` |
| paddy@99 | 209 } |
| paddy@99 | 210 |
| paddy@99 | 211 func validateNewProfileRequest(req *newProfileRequest) []requestError { |
| paddy@99 | 212 errors := []requestError{} |
| paddy@99 | 213 req.Name = strings.TrimSpace(req.Name) |
| paddy@99 | 214 req.Email = strings.TrimSpace(req.Email) |
| paddy@99 | 215 req.Username = slug.SlugAscii(strings.TrimSpace(req.Username)) |
| paddy@99 | 216 if len(req.Passphrase) < MinPassphraseLength { |
| paddy@99 | 217 errors = append(errors, requestError{ |
| paddy@99 | 218 Slug: requestErrInsufficient, |
| paddy@99 | 219 Field: "/passphrase", |
| paddy@99 | 220 }) |
| paddy@99 | 221 } |
| paddy@99 | 222 if len(req.Passphrase) > MaxPassphraseLength { |
| paddy@99 | 223 errors = append(errors, requestError{ |
| paddy@99 | 224 Slug: requestErrOverflow, |
| paddy@99 | 225 Field: "/passphrase", |
| paddy@99 | 226 }) |
| paddy@99 | 227 } |
| paddy@99 | 228 if len(req.Name) > MaxNameLength { |
| paddy@99 | 229 errors = append(errors, requestError{ |
| paddy@99 | 230 Slug: requestErrOverflow, |
| paddy@99 | 231 Field: "/name", |
| paddy@99 | 232 }) |
| paddy@99 | 233 } |
| paddy@99 | 234 if len(req.Username) > MaxUsernameLength { |
| paddy@99 | 235 errors = append(errors, requestError{ |
| paddy@99 | 236 Slug: requestErrOverflow, |
| paddy@99 | 237 Field: "/username", |
| paddy@99 | 238 }) |
| paddy@99 | 239 } |
| paddy@99 | 240 if req.Email == "" { |
| paddy@99 | 241 errors = append(errors, requestError{ |
| paddy@99 | 242 Slug: requestErrMissing, |
| paddy@99 | 243 Field: "/email", |
| paddy@99 | 244 }) |
| paddy@99 | 245 } |
| paddy@99 | 246 if len(req.Email) > MaxEmailLength { |
| paddy@99 | 247 errors = append(errors, requestError{ |
| paddy@99 | 248 Slug: requestErrOverflow, |
| paddy@99 | 249 Field: "/email", |
| paddy@99 | 250 }) |
| paddy@99 | 251 } |
| paddy@99 | 252 re := regexp.MustCompile(".+@.+\\..+") |
| paddy@99 | 253 if !re.Match([]byte(req.Email)) { |
| paddy@99 | 254 errors = append(errors, requestError{ |
| paddy@105 | 255 Slug: requestErrInvalidFormat, |
| paddy@99 | 256 Field: "/email", |
| paddy@99 | 257 }) |
| paddy@99 | 258 } |
| paddy@99 | 259 return errors |
| paddy@99 | 260 } |
| paddy@99 | 261 |
| paddy@57 | 262 type profileStore interface { |
| paddy@57 | 263 getProfileByID(id uuid.ID) (Profile, error) |
| paddy@69 | 264 getProfileByLogin(value string) (Profile, error) |
| paddy@57 | 265 saveProfile(profile Profile) error |
| paddy@57 | 266 updateProfile(id uuid.ID, change ProfileChange) error |
| paddy@57 | 267 updateProfiles(ids []uuid.ID, change BulkProfileChange) error |
| paddy@44 | 268 |
| paddy@57 | 269 addLogin(login Login) error |
| paddy@69 | 270 removeLogin(value string, profile uuid.ID) error |
| paddy@69 | 271 recordLoginUse(value string, when time.Time) error |
| paddy@57 | 272 listLogins(profile uuid.ID, num, offset int) ([]Login, error) |
| paddy@38 | 273 } |
| paddy@27 | 274 |
| paddy@57 | 275 func (m *memstore) getProfileByID(id uuid.ID) (Profile, error) { |
| paddy@38 | 276 m.profileLock.RLock() |
| paddy@38 | 277 defer m.profileLock.RUnlock() |
| paddy@38 | 278 p, ok := m.profiles[id.String()] |
| paddy@38 | 279 if !ok { |
| paddy@38 | 280 return Profile{}, ErrProfileNotFound |
| paddy@38 | 281 } |
| paddy@148 | 282 if p.Deleted { |
| paddy@148 | 283 return Profile{}, ErrProfileNotFound |
| paddy@148 | 284 } |
| paddy@38 | 285 return p, nil |
| paddy@27 | 286 } |
| paddy@38 | 287 |
| paddy@69 | 288 func (m *memstore) getProfileByLogin(value string) (Profile, error) { |
| paddy@44 | 289 m.loginLock.RLock() |
| paddy@44 | 290 defer m.loginLock.RUnlock() |
| paddy@69 | 291 login, ok := m.logins[value] |
| paddy@44 | 292 if !ok { |
| paddy@44 | 293 return Profile{}, ErrLoginNotFound |
| paddy@44 | 294 } |
| paddy@44 | 295 m.profileLock.RLock() |
| paddy@44 | 296 defer m.profileLock.RUnlock() |
| paddy@44 | 297 profile, ok := m.profiles[login.ProfileID.String()] |
| paddy@44 | 298 if !ok { |
| paddy@44 | 299 return Profile{}, ErrProfileNotFound |
| paddy@44 | 300 } |
| paddy@148 | 301 if profile.Deleted { |
| paddy@148 | 302 return Profile{}, ErrProfileNotFound |
| paddy@148 | 303 } |
| paddy@44 | 304 return profile, nil |
| paddy@38 | 305 } |
| paddy@38 | 306 |
| paddy@57 | 307 func (m *memstore) saveProfile(profile Profile) error { |
| paddy@38 | 308 m.profileLock.Lock() |
| paddy@38 | 309 defer m.profileLock.Unlock() |
| paddy@38 | 310 _, ok := m.profiles[profile.ID.String()] |
| paddy@38 | 311 if ok { |
| paddy@38 | 312 return ErrProfileAlreadyExists |
| paddy@38 | 313 } |
| paddy@38 | 314 m.profiles[profile.ID.String()] = profile |
| paddy@38 | 315 return nil |
| paddy@38 | 316 } |
| paddy@38 | 317 |
| paddy@57 | 318 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error { |
| paddy@38 | 319 m.profileLock.Lock() |
| paddy@38 | 320 defer m.profileLock.Unlock() |
| paddy@38 | 321 p, ok := m.profiles[id.String()] |
| paddy@38 | 322 if !ok { |
| paddy@38 | 323 return ErrProfileNotFound |
| paddy@38 | 324 } |
| paddy@38 | 325 p.ApplyChange(change) |
| paddy@38 | 326 m.profiles[id.String()] = p |
| paddy@38 | 327 return nil |
| paddy@38 | 328 } |
| paddy@38 | 329 |
| paddy@57 | 330 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error { |
| paddy@44 | 331 m.profileLock.Lock() |
| paddy@44 | 332 defer m.profileLock.Unlock() |
| paddy@44 | 333 for id, profile := range m.profiles { |
| paddy@44 | 334 for _, i := range ids { |
| paddy@44 | 335 if id == i.String() { |
| paddy@44 | 336 profile.ApplyBulkChange(change) |
| paddy@44 | 337 m.profiles[id] = profile |
| paddy@44 | 338 break |
| paddy@44 | 339 } |
| paddy@44 | 340 } |
| paddy@44 | 341 } |
| paddy@44 | 342 return nil |
| paddy@44 | 343 } |
| paddy@44 | 344 |
| paddy@57 | 345 func (m *memstore) addLogin(login Login) error { |
| paddy@44 | 346 m.loginLock.Lock() |
| paddy@44 | 347 defer m.loginLock.Unlock() |
| paddy@69 | 348 _, ok := m.logins[login.Value] |
| paddy@44 | 349 if ok { |
| paddy@44 | 350 return ErrLoginAlreadyExists |
| paddy@44 | 351 } |
| paddy@69 | 352 m.logins[login.Value] = login |
| paddy@69 | 353 m.profileLoginLookup[login.ProfileID.String()] = append(m.profileLoginLookup[login.ProfileID.String()], login.Value) |
| paddy@44 | 354 return nil |
| paddy@44 | 355 } |
| paddy@44 | 356 |
| paddy@69 | 357 func (m *memstore) removeLogin(value string, profile uuid.ID) error { |
| paddy@44 | 358 m.loginLock.Lock() |
| paddy@44 | 359 defer m.loginLock.Unlock() |
| paddy@69 | 360 l, ok := m.logins[value] |
| paddy@44 | 361 if !ok { |
| paddy@44 | 362 return ErrLoginNotFound |
| paddy@44 | 363 } |
| paddy@44 | 364 if !l.ProfileID.Equal(profile) { |
| paddy@44 | 365 return ErrLoginNotFound |
| paddy@44 | 366 } |
| paddy@69 | 367 delete(m.logins, value) |
| paddy@44 | 368 pos := -1 |
| paddy@44 | 369 for p, id := range m.profileLoginLookup[profile.String()] { |
| paddy@69 | 370 if id == value { |
| paddy@44 | 371 pos = p |
| paddy@44 | 372 break |
| paddy@44 | 373 } |
| paddy@44 | 374 } |
| paddy@44 | 375 if pos >= 0 { |
| paddy@44 | 376 m.profileLoginLookup[profile.String()] = append(m.profileLoginLookup[profile.String()][:pos], m.profileLoginLookup[profile.String()][pos+1:]...) |
| paddy@44 | 377 } |
| paddy@44 | 378 return nil |
| paddy@44 | 379 } |
| paddy@44 | 380 |
| paddy@69 | 381 func (m *memstore) recordLoginUse(value string, when time.Time) error { |
| paddy@44 | 382 m.loginLock.Lock() |
| paddy@44 | 383 defer m.loginLock.Unlock() |
| paddy@69 | 384 l, ok := m.logins[value] |
| paddy@44 | 385 if !ok { |
| paddy@44 | 386 return ErrLoginNotFound |
| paddy@44 | 387 } |
| paddy@44 | 388 l.LastUsed = when |
| paddy@69 | 389 m.logins[value] = l |
| paddy@44 | 390 return nil |
| paddy@44 | 391 } |
| paddy@44 | 392 |
| paddy@57 | 393 func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) { |
| paddy@44 | 394 m.loginLock.RLock() |
| paddy@44 | 395 defer m.loginLock.RUnlock() |
| paddy@44 | 396 ids, ok := m.profileLoginLookup[profile.String()] |
| paddy@44 | 397 if !ok { |
| paddy@44 | 398 return []Login{}, nil |
| paddy@44 | 399 } |
| paddy@44 | 400 if len(ids) > num+offset { |
| paddy@44 | 401 ids = ids[offset : num+offset] |
| paddy@44 | 402 } else if len(ids) > offset { |
| paddy@44 | 403 ids = ids[offset:] |
| paddy@44 | 404 } else { |
| paddy@44 | 405 return []Login{}, nil |
| paddy@44 | 406 } |
| paddy@44 | 407 logins := []Login{} |
| paddy@44 | 408 for _, id := range ids { |
| paddy@44 | 409 login, ok := m.logins[id] |
| paddy@44 | 410 if !ok { |
| paddy@44 | 411 continue |
| paddy@44 | 412 } |
| paddy@44 | 413 logins = append(logins, login) |
| paddy@44 | 414 } |
| paddy@44 | 415 return logins, nil |
| paddy@44 | 416 } |
| paddy@99 | 417 |
| paddy@105 | 418 // RegisterProfileHandlers adds handlers to the passed router to handle the profile endpoints, like registration and user retrieval. |
| paddy@105 | 419 func RegisterProfileHandlers(r *mux.Router, context Context) { |
| paddy@105 | 420 r.Handle("/profiles", wrap(context, CreateProfileHandler)).Methods("POST") |
| paddy@128 | 421 // BUG(paddy): We need to implement a handler that will return information about a profile or set of profiles. |
| paddy@145 | 422 r.Handle("/profiles/{id}", wrap(context, UpdateProfileHandler)).Methods("PATCH") |
| paddy@128 | 423 // BUG(paddy): We need to implement a handler that will delete a profile. What happens to clients/tokens/grants/sessions when a profile is deleted? |
| paddy@128 | 424 // BUG(paddy): We need to implement a handler that will add a login to a profile. |
| paddy@128 | 425 // 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 | 426 // BUG(paddy): We need to implement a handler that will list the logins attached to a profile. |
| paddy@105 | 427 } |
| paddy@105 | 428 |
| paddy@99 | 429 // CreateProfileHandler is an HTTP handler for registering new profiles. |
| paddy@99 | 430 func CreateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@99 | 431 scheme, ok := passphraseSchemes[CurPassphraseScheme] |
| paddy@99 | 432 if !ok { |
| paddy@105 | 433 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 434 return |
| paddy@99 | 435 } |
| paddy@99 | 436 var req newProfileRequest |
| paddy@99 | 437 errors := []requestError{} |
| paddy@99 | 438 decoder := json.NewDecoder(r.Body) |
| paddy@99 | 439 err := decoder.Decode(&req) |
| paddy@99 | 440 if err != nil { |
| paddy@105 | 441 encode(w, r, http.StatusBadRequest, invalidFormatResponse) |
| paddy@99 | 442 return |
| paddy@99 | 443 } |
| paddy@99 | 444 errors = append(errors, validateNewProfileRequest(&req)...) |
| paddy@99 | 445 if len(errors) > 0 { |
| paddy@105 | 446 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@99 | 447 return |
| paddy@99 | 448 } |
| paddy@99 | 449 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations) |
| paddy@99 | 450 if err != nil { |
| paddy@105 | 451 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 452 return |
| paddy@99 | 453 } |
| paddy@99 | 454 profile := Profile{ |
| paddy@99 | 455 ID: uuid.NewID(), |
| paddy@99 | 456 Name: req.Name, |
| paddy@99 | 457 Passphrase: string(passphrase), |
| paddy@99 | 458 Iterations: context.config.iterations, |
| paddy@99 | 459 Salt: string(salt), |
| paddy@99 | 460 PassphraseScheme: CurPassphraseScheme, |
| paddy@99 | 461 Created: time.Now(), |
| paddy@99 | 462 LastSeen: time.Now(), |
| paddy@99 | 463 } |
| paddy@99 | 464 err = context.SaveProfile(profile) |
| paddy@99 | 465 if err != nil { |
| paddy@105 | 466 if err == ErrProfileAlreadyExists { |
| paddy@105 | 467 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/id"}}}) |
| paddy@105 | 468 return |
| paddy@105 | 469 } |
| paddy@105 | 470 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 471 return |
| paddy@99 | 472 } |
| paddy@99 | 473 logins := []Login{} |
| paddy@99 | 474 login := Login{ |
| paddy@99 | 475 Type: "email", |
| paddy@99 | 476 Value: req.Email, |
| paddy@99 | 477 Created: profile.Created, |
| paddy@99 | 478 LastUsed: profile.Created, |
| paddy@99 | 479 ProfileID: profile.ID, |
| paddy@99 | 480 } |
| paddy@99 | 481 err = context.AddLogin(login) |
| paddy@99 | 482 if err != nil { |
| paddy@105 | 483 if err == ErrLoginAlreadyExists { |
| paddy@105 | 484 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/email"}}}) |
| paddy@105 | 485 return |
| paddy@105 | 486 } |
| paddy@105 | 487 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 488 return |
| paddy@99 | 489 } |
| paddy@99 | 490 logins = append(logins, login) |
| paddy@99 | 491 if req.Username != "" { |
| paddy@99 | 492 login.Type = "username" |
| paddy@99 | 493 login.Value = req.Username |
| paddy@99 | 494 err = context.AddLogin(login) |
| paddy@99 | 495 if err != nil { |
| paddy@105 | 496 if err == ErrLoginAlreadyExists { |
| paddy@105 | 497 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/username"}}}) |
| paddy@105 | 498 return |
| paddy@105 | 499 } |
| paddy@105 | 500 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 501 return |
| paddy@99 | 502 } |
| paddy@99 | 503 logins = append(logins, login) |
| paddy@99 | 504 } |
| paddy@105 | 505 resp := response{ |
| paddy@105 | 506 Logins: logins, |
| paddy@105 | 507 Profiles: []Profile{profile}, |
| paddy@105 | 508 } |
| paddy@105 | 509 encode(w, r, http.StatusCreated, resp) |
| paddy@99 | 510 // TODO(paddy): should we kick off the email validation flow? |
| paddy@99 | 511 } |
| paddy@145 | 512 |
| paddy@145 | 513 func UpdateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@145 | 514 errors := []requestError{} |
| paddy@145 | 515 vars := mux.Vars(r) |
| paddy@145 | 516 if vars["id"] == "" { |
| paddy@145 | 517 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"}) |
| paddy@145 | 518 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 519 return |
| paddy@145 | 520 } |
| paddy@145 | 521 id, err := uuid.Parse(vars["id"]) |
| paddy@145 | 522 if err != nil { |
| paddy@145 | 523 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 524 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 525 return |
| paddy@145 | 526 } |
| paddy@145 | 527 username, password, ok := r.BasicAuth() |
| paddy@145 | 528 if !ok { |
| paddy@145 | 529 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 530 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@145 | 531 return |
| paddy@145 | 532 } |
| paddy@145 | 533 profile, err := authenticate(username, password, context) |
| paddy@145 | 534 if err != nil { |
| paddy@145 | 535 if isAuthError(err) { |
| paddy@145 | 536 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 537 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@145 | 538 } else { |
| paddy@145 | 539 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@145 | 540 encode(w, r, http.StatusInternalServerError, response{Errors: errors}) |
| paddy@145 | 541 } |
| paddy@145 | 542 return |
| paddy@145 | 543 } |
| paddy@145 | 544 if !profile.ID.Equal(id) { |
| paddy@145 | 545 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 546 encode(w, r, http.StatusForbidden, response{Errors: errors}) |
| paddy@145 | 547 return |
| paddy@145 | 548 } |
| paddy@145 | 549 var req ProfileChange |
| paddy@145 | 550 decoder := json.NewDecoder(r.Body) |
| paddy@145 | 551 err = decoder.Decode(&req) |
| paddy@145 | 552 if err != nil { |
| paddy@145 | 553 encode(w, r, http.StatusBadRequest, invalidFormatResponse) |
| paddy@145 | 554 return |
| paddy@145 | 555 } |
| paddy@145 | 556 req.Iterations = nil |
| paddy@145 | 557 req.Salt = nil |
| paddy@145 | 558 req.PassphraseScheme = nil |
| paddy@145 | 559 req.Compromised = nil // BUG(paddy): Need a way for admins to mark accounts as compromised |
| paddy@145 | 560 req.LockedUntil = nil |
| paddy@145 | 561 req.LastSeen = nil |
| paddy@145 | 562 if req.Passphrase != nil { |
| paddy@145 | 563 if len(*req.Passphrase) < MinPassphraseLength { |
| paddy@145 | 564 errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/passphrase"}) |
| paddy@145 | 565 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 566 return |
| paddy@145 | 567 } |
| paddy@145 | 568 if len(*req.Passphrase) > MaxPassphraseLength { |
| paddy@145 | 569 errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/passphrase"}) |
| paddy@145 | 570 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 571 return |
| paddy@145 | 572 } |
| paddy@145 | 573 iterations := context.config.iterations |
| paddy@145 | 574 scheme, ok := passphraseSchemes[CurPassphraseScheme] |
| paddy@145 | 575 if !ok { |
| paddy@145 | 576 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 577 return |
| paddy@145 | 578 } |
| paddy@145 | 579 curScheme := CurPassphraseScheme |
| paddy@145 | 580 req.PassphraseScheme = &curScheme |
| paddy@145 | 581 passphrase, salt, err := scheme.create(*req.Passphrase, iterations) |
| paddy@145 | 582 if err != nil { |
| paddy@145 | 583 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 584 return |
| paddy@145 | 585 } |
| paddy@145 | 586 req.Passphrase = &passphrase |
| paddy@145 | 587 req.Salt = &salt |
| paddy@145 | 588 req.Iterations = &iterations |
| paddy@145 | 589 } |
| paddy@145 | 590 if req.PassphraseReset != nil { |
| paddy@145 | 591 now := time.Now() |
| paddy@145 | 592 req.PassphraseResetCreated = &now |
| paddy@145 | 593 } |
| paddy@145 | 594 err = req.Validate() |
| paddy@145 | 595 if err != nil { |
| paddy@145 | 596 var status int |
| paddy@145 | 597 var resp response |
| paddy@145 | 598 switch err { |
| paddy@145 | 599 case ErrEmptyChange: |
| paddy@145 | 600 resp.Profiles = []Profile{profile} |
| paddy@145 | 601 status = http.StatusOK |
| paddy@145 | 602 default: |
| paddy@145 | 603 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@145 | 604 resp.Errors = errors |
| paddy@145 | 605 status = http.StatusInternalServerError |
| paddy@145 | 606 } |
| paddy@145 | 607 encode(w, r, status, resp) |
| paddy@145 | 608 return |
| paddy@145 | 609 } |
| paddy@145 | 610 err = context.UpdateProfile(id, req) |
| paddy@145 | 611 if err != nil { |
| paddy@145 | 612 if err == ErrProfileNotFound { |
| paddy@145 | 613 errors = append(errors, requestError{Slug: requestErrNotFound}) |
| paddy@145 | 614 encode(w, r, http.StatusNotFound, response{Errors: errors}) |
| paddy@145 | 615 return |
| paddy@145 | 616 } |
| paddy@145 | 617 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 618 return |
| paddy@145 | 619 } |
| paddy@145 | 620 profile.ApplyChange(req) |
| paddy@145 | 621 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}}) |
| paddy@145 | 622 return |
| paddy@145 | 623 } |