auth
auth/profile.go
Randomly generate codes. We've been using our IDs for auth codes. But our IDs may at some point be non-random, for the purpose of optimising database performance, or some other perfectly valid reason. Auth codes we always want to be random, and have no relation to IDs, so why conflate them? Instead, we pull 16 random bytes out of crypto/rand.Reader and hex encode them.
| 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@38 | 85 } |
| paddy@38 | 86 |
| paddy@57 | 87 // ApplyChange applies the properties of the passed ProfileChange |
| paddy@57 | 88 // to the Profile it is called on. |
| paddy@38 | 89 func (p *Profile) ApplyChange(change ProfileChange) { |
| paddy@38 | 90 if change.Name != nil { |
| paddy@38 | 91 p.Name = *change.Name |
| paddy@38 | 92 } |
| paddy@38 | 93 if change.Passphrase != nil { |
| paddy@38 | 94 p.Passphrase = *change.Passphrase |
| paddy@38 | 95 } |
| paddy@38 | 96 if change.Iterations != nil { |
| paddy@38 | 97 p.Iterations = *change.Iterations |
| paddy@38 | 98 } |
| paddy@38 | 99 if change.Salt != nil { |
| paddy@38 | 100 p.Salt = *change.Salt |
| paddy@38 | 101 } |
| paddy@38 | 102 if change.PassphraseScheme != nil { |
| paddy@38 | 103 p.PassphraseScheme = *change.PassphraseScheme |
| paddy@38 | 104 } |
| paddy@38 | 105 if change.Compromised != nil { |
| paddy@38 | 106 p.Compromised = *change.Compromised |
| paddy@38 | 107 } |
| paddy@38 | 108 if change.LockedUntil != nil { |
| paddy@38 | 109 p.LockedUntil = *change.LockedUntil |
| paddy@38 | 110 } |
| paddy@38 | 111 if change.PassphraseReset != nil { |
| paddy@38 | 112 p.PassphraseReset = *change.PassphraseReset |
| paddy@38 | 113 } |
| paddy@38 | 114 if change.PassphraseResetCreated != nil { |
| paddy@38 | 115 p.PassphraseResetCreated = *change.PassphraseResetCreated |
| paddy@38 | 116 } |
| paddy@38 | 117 if change.LastSeen != nil { |
| paddy@38 | 118 p.LastSeen = *change.LastSeen |
| paddy@38 | 119 } |
| paddy@38 | 120 } |
| paddy@38 | 121 |
| paddy@57 | 122 // ApplyBulkChange applies the properties of the passed BulkProfileChange |
| paddy@57 | 123 // to the Profile it is called on. |
| paddy@44 | 124 func (p *Profile) ApplyBulkChange(change BulkProfileChange) { |
| paddy@44 | 125 if change.Compromised != nil { |
| paddy@44 | 126 p.Compromised = *change.Compromised |
| paddy@44 | 127 } |
| paddy@44 | 128 } |
| paddy@44 | 129 |
| paddy@57 | 130 // ProfileChange represents a single atomic change to a Profile's mutable data. |
| paddy@38 | 131 type ProfileChange struct { |
| paddy@38 | 132 Name *string |
| paddy@38 | 133 Passphrase *string |
| paddy@69 | 134 Iterations *int |
| paddy@38 | 135 Salt *string |
| paddy@38 | 136 PassphraseScheme *int |
| paddy@38 | 137 Compromised *bool |
| paddy@38 | 138 LockedUntil *time.Time |
| paddy@38 | 139 PassphraseReset *string |
| paddy@38 | 140 PassphraseResetCreated *time.Time |
| paddy@38 | 141 LastSeen *time.Time |
| paddy@38 | 142 } |
| paddy@38 | 143 |
| paddy@57 | 144 // Validate checks the ProfileChange it is called on |
| paddy@57 | 145 // and asserts its internal validity, or lack thereof. |
| paddy@57 | 146 // A descriptive error will be returned in the case of |
| paddy@57 | 147 // an invalid change. |
| paddy@38 | 148 func (c ProfileChange) Validate() error { |
| paddy@48 | 149 if c.Name == nil && c.Passphrase == nil && c.Iterations == nil && c.Salt == nil && c.PassphraseScheme == nil && c.Compromised == nil && c.LockedUntil == nil && c.PassphraseReset == nil && c.PassphraseResetCreated == nil && c.LastSeen == nil { |
| paddy@48 | 150 return ErrEmptyChange |
| paddy@48 | 151 } |
| paddy@48 | 152 if c.PassphraseScheme != nil && c.Passphrase == nil { |
| paddy@48 | 153 return ErrMissingPassphrase |
| paddy@48 | 154 } |
| paddy@48 | 155 if c.PassphraseReset != nil && c.PassphraseResetCreated == nil { |
| paddy@48 | 156 return ErrMissingPassphraseResetCreated |
| paddy@48 | 157 } |
| paddy@48 | 158 if c.PassphraseReset == nil && c.PassphraseResetCreated != nil { |
| paddy@48 | 159 return ErrMissingPassphraseReset |
| paddy@48 | 160 } |
| paddy@48 | 161 if c.Salt != nil && c.Passphrase == nil { |
| paddy@48 | 162 return ErrMissingPassphrase |
| paddy@48 | 163 } |
| paddy@48 | 164 if c.Iterations != nil && c.Passphrase == nil { |
| paddy@48 | 165 return ErrMissingPassphrase |
| paddy@48 | 166 } |
| paddy@38 | 167 return nil |
| paddy@27 | 168 } |
| paddy@27 | 169 |
| paddy@57 | 170 // BulkProfileChange represents a single atomic change to many Profiles' mutable data. |
| paddy@57 | 171 // It is a subset of a ProfileChange, as it doesn't make sense to mutate some of the |
| paddy@57 | 172 // ProfileChange values across many Profiles all at once. |
| paddy@44 | 173 type BulkProfileChange struct { |
| paddy@44 | 174 Compromised *bool |
| paddy@44 | 175 } |
| paddy@44 | 176 |
| paddy@57 | 177 // Validate checks the BulkProfileChange it is called on |
| paddy@57 | 178 // and asserts its internal validity, or lack thereof. |
| paddy@57 | 179 // A descriptive error will be returned in the case of an |
| paddy@57 | 180 // invalid change. |
| paddy@44 | 181 func (b BulkProfileChange) Validate() error { |
| paddy@48 | 182 if b.Compromised == nil { |
| paddy@48 | 183 return ErrEmptyChange |
| paddy@48 | 184 } |
| paddy@44 | 185 return nil |
| paddy@44 | 186 } |
| paddy@44 | 187 |
| paddy@57 | 188 // Login represents a single human-friendly identifier for |
| paddy@57 | 189 // a given Profile that can be used to log into that Profile. |
| paddy@57 | 190 // Each Profile may only have one Login for each Type. |
| paddy@27 | 191 type Login struct { |
| paddy@105 | 192 Type string `json:"type,omitempty"` |
| paddy@105 | 193 Value string `json:"value,omitempty"` |
| paddy@105 | 194 ProfileID uuid.ID `json:"profile_id,omitempty"` |
| paddy@105 | 195 Created time.Time `json:"created,omitempty"` |
| paddy@105 | 196 LastUsed time.Time `json:"last_used,omitempty"` |
| paddy@27 | 197 } |
| paddy@27 | 198 |
| paddy@99 | 199 type newProfileRequest struct { |
| paddy@99 | 200 Username string `json:"username"` |
| paddy@99 | 201 Email string `json:"email"` |
| paddy@99 | 202 Passphrase string `json:"passphrase"` |
| paddy@99 | 203 Name string `json:"name"` |
| paddy@99 | 204 } |
| paddy@99 | 205 |
| paddy@99 | 206 func validateNewProfileRequest(req *newProfileRequest) []requestError { |
| paddy@99 | 207 errors := []requestError{} |
| paddy@99 | 208 req.Name = strings.TrimSpace(req.Name) |
| paddy@99 | 209 req.Email = strings.TrimSpace(req.Email) |
| paddy@99 | 210 req.Username = slug.SlugAscii(strings.TrimSpace(req.Username)) |
| paddy@99 | 211 if len(req.Passphrase) < MinPassphraseLength { |
| paddy@99 | 212 errors = append(errors, requestError{ |
| paddy@99 | 213 Slug: requestErrInsufficient, |
| paddy@99 | 214 Field: "/passphrase", |
| paddy@99 | 215 }) |
| paddy@99 | 216 } |
| paddy@99 | 217 if len(req.Passphrase) > MaxPassphraseLength { |
| paddy@99 | 218 errors = append(errors, requestError{ |
| paddy@99 | 219 Slug: requestErrOverflow, |
| paddy@99 | 220 Field: "/passphrase", |
| paddy@99 | 221 }) |
| paddy@99 | 222 } |
| paddy@99 | 223 if len(req.Name) > MaxNameLength { |
| paddy@99 | 224 errors = append(errors, requestError{ |
| paddy@99 | 225 Slug: requestErrOverflow, |
| paddy@99 | 226 Field: "/name", |
| paddy@99 | 227 }) |
| paddy@99 | 228 } |
| paddy@99 | 229 if len(req.Username) > MaxUsernameLength { |
| paddy@99 | 230 errors = append(errors, requestError{ |
| paddy@99 | 231 Slug: requestErrOverflow, |
| paddy@99 | 232 Field: "/username", |
| paddy@99 | 233 }) |
| paddy@99 | 234 } |
| paddy@99 | 235 if req.Email == "" { |
| paddy@99 | 236 errors = append(errors, requestError{ |
| paddy@99 | 237 Slug: requestErrMissing, |
| paddy@99 | 238 Field: "/email", |
| paddy@99 | 239 }) |
| paddy@99 | 240 } |
| paddy@99 | 241 if len(req.Email) > MaxEmailLength { |
| paddy@99 | 242 errors = append(errors, requestError{ |
| paddy@99 | 243 Slug: requestErrOverflow, |
| paddy@99 | 244 Field: "/email", |
| paddy@99 | 245 }) |
| paddy@99 | 246 } |
| paddy@99 | 247 re := regexp.MustCompile(".+@.+\\..+") |
| paddy@99 | 248 if !re.Match([]byte(req.Email)) { |
| paddy@99 | 249 errors = append(errors, requestError{ |
| paddy@105 | 250 Slug: requestErrInvalidFormat, |
| paddy@99 | 251 Field: "/email", |
| paddy@99 | 252 }) |
| paddy@99 | 253 } |
| paddy@99 | 254 return errors |
| paddy@99 | 255 } |
| paddy@99 | 256 |
| paddy@57 | 257 type profileStore interface { |
| paddy@57 | 258 getProfileByID(id uuid.ID) (Profile, error) |
| paddy@69 | 259 getProfileByLogin(value string) (Profile, error) |
| paddy@57 | 260 saveProfile(profile Profile) error |
| paddy@57 | 261 updateProfile(id uuid.ID, change ProfileChange) error |
| paddy@57 | 262 updateProfiles(ids []uuid.ID, change BulkProfileChange) error |
| paddy@57 | 263 deleteProfile(id uuid.ID) error |
| paddy@44 | 264 |
| paddy@57 | 265 addLogin(login Login) error |
| paddy@69 | 266 removeLogin(value string, profile uuid.ID) error |
| paddy@69 | 267 recordLoginUse(value string, when time.Time) error |
| paddy@57 | 268 listLogins(profile uuid.ID, num, offset int) ([]Login, error) |
| paddy@38 | 269 } |
| paddy@27 | 270 |
| paddy@57 | 271 func (m *memstore) getProfileByID(id uuid.ID) (Profile, error) { |
| paddy@38 | 272 m.profileLock.RLock() |
| paddy@38 | 273 defer m.profileLock.RUnlock() |
| paddy@38 | 274 p, ok := m.profiles[id.String()] |
| paddy@38 | 275 if !ok { |
| paddy@38 | 276 return Profile{}, ErrProfileNotFound |
| paddy@38 | 277 } |
| paddy@38 | 278 return p, nil |
| paddy@27 | 279 } |
| paddy@38 | 280 |
| paddy@69 | 281 func (m *memstore) getProfileByLogin(value string) (Profile, error) { |
| paddy@44 | 282 m.loginLock.RLock() |
| paddy@44 | 283 defer m.loginLock.RUnlock() |
| paddy@69 | 284 login, ok := m.logins[value] |
| paddy@44 | 285 if !ok { |
| paddy@44 | 286 return Profile{}, ErrLoginNotFound |
| paddy@44 | 287 } |
| paddy@44 | 288 m.profileLock.RLock() |
| paddy@44 | 289 defer m.profileLock.RUnlock() |
| paddy@44 | 290 profile, ok := m.profiles[login.ProfileID.String()] |
| paddy@44 | 291 if !ok { |
| paddy@44 | 292 return Profile{}, ErrProfileNotFound |
| paddy@44 | 293 } |
| paddy@44 | 294 return profile, nil |
| paddy@38 | 295 } |
| paddy@38 | 296 |
| paddy@57 | 297 func (m *memstore) saveProfile(profile Profile) error { |
| paddy@38 | 298 m.profileLock.Lock() |
| paddy@38 | 299 defer m.profileLock.Unlock() |
| paddy@38 | 300 _, ok := m.profiles[profile.ID.String()] |
| paddy@38 | 301 if ok { |
| paddy@38 | 302 return ErrProfileAlreadyExists |
| paddy@38 | 303 } |
| paddy@38 | 304 m.profiles[profile.ID.String()] = profile |
| paddy@38 | 305 return nil |
| paddy@38 | 306 } |
| paddy@38 | 307 |
| paddy@57 | 308 func (m *memstore) updateProfile(id uuid.ID, change ProfileChange) error { |
| paddy@38 | 309 m.profileLock.Lock() |
| paddy@38 | 310 defer m.profileLock.Unlock() |
| paddy@38 | 311 p, ok := m.profiles[id.String()] |
| paddy@38 | 312 if !ok { |
| paddy@38 | 313 return ErrProfileNotFound |
| paddy@38 | 314 } |
| paddy@38 | 315 p.ApplyChange(change) |
| paddy@38 | 316 m.profiles[id.String()] = p |
| paddy@38 | 317 return nil |
| paddy@38 | 318 } |
| paddy@38 | 319 |
| paddy@57 | 320 func (m *memstore) updateProfiles(ids []uuid.ID, change BulkProfileChange) error { |
| paddy@44 | 321 m.profileLock.Lock() |
| paddy@44 | 322 defer m.profileLock.Unlock() |
| paddy@44 | 323 for id, profile := range m.profiles { |
| paddy@44 | 324 for _, i := range ids { |
| paddy@44 | 325 if id == i.String() { |
| paddy@44 | 326 profile.ApplyBulkChange(change) |
| paddy@44 | 327 m.profiles[id] = profile |
| paddy@44 | 328 break |
| paddy@44 | 329 } |
| paddy@44 | 330 } |
| paddy@44 | 331 } |
| paddy@44 | 332 return nil |
| paddy@44 | 333 } |
| paddy@44 | 334 |
| paddy@57 | 335 func (m *memstore) deleteProfile(id uuid.ID) error { |
| paddy@38 | 336 m.profileLock.Lock() |
| paddy@38 | 337 defer m.profileLock.Unlock() |
| paddy@38 | 338 _, ok := m.profiles[id.String()] |
| paddy@38 | 339 if !ok { |
| paddy@38 | 340 return ErrProfileNotFound |
| paddy@38 | 341 } |
| paddy@38 | 342 delete(m.profiles, id.String()) |
| paddy@38 | 343 return nil |
| paddy@38 | 344 } |
| paddy@40 | 345 |
| paddy@57 | 346 func (m *memstore) addLogin(login Login) error { |
| paddy@44 | 347 m.loginLock.Lock() |
| paddy@44 | 348 defer m.loginLock.Unlock() |
| paddy@69 | 349 _, ok := m.logins[login.Value] |
| paddy@44 | 350 if ok { |
| paddy@44 | 351 return ErrLoginAlreadyExists |
| paddy@44 | 352 } |
| paddy@69 | 353 m.logins[login.Value] = login |
| paddy@69 | 354 m.profileLoginLookup[login.ProfileID.String()] = append(m.profileLoginLookup[login.ProfileID.String()], login.Value) |
| paddy@44 | 355 return nil |
| paddy@44 | 356 } |
| paddy@44 | 357 |
| paddy@69 | 358 func (m *memstore) removeLogin(value string, profile uuid.ID) error { |
| paddy@44 | 359 m.loginLock.Lock() |
| paddy@44 | 360 defer m.loginLock.Unlock() |
| paddy@69 | 361 l, ok := m.logins[value] |
| paddy@44 | 362 if !ok { |
| paddy@44 | 363 return ErrLoginNotFound |
| paddy@44 | 364 } |
| paddy@44 | 365 if !l.ProfileID.Equal(profile) { |
| paddy@44 | 366 return ErrLoginNotFound |
| paddy@44 | 367 } |
| paddy@69 | 368 delete(m.logins, value) |
| paddy@44 | 369 pos := -1 |
| paddy@44 | 370 for p, id := range m.profileLoginLookup[profile.String()] { |
| paddy@69 | 371 if id == value { |
| paddy@44 | 372 pos = p |
| paddy@44 | 373 break |
| paddy@44 | 374 } |
| paddy@44 | 375 } |
| paddy@44 | 376 if pos >= 0 { |
| paddy@44 | 377 m.profileLoginLookup[profile.String()] = append(m.profileLoginLookup[profile.String()][:pos], m.profileLoginLookup[profile.String()][pos+1:]...) |
| paddy@44 | 378 } |
| paddy@44 | 379 return nil |
| paddy@44 | 380 } |
| paddy@44 | 381 |
| paddy@69 | 382 func (m *memstore) recordLoginUse(value string, when time.Time) error { |
| paddy@44 | 383 m.loginLock.Lock() |
| paddy@44 | 384 defer m.loginLock.Unlock() |
| paddy@69 | 385 l, ok := m.logins[value] |
| paddy@44 | 386 if !ok { |
| paddy@44 | 387 return ErrLoginNotFound |
| paddy@44 | 388 } |
| paddy@44 | 389 l.LastUsed = when |
| paddy@69 | 390 m.logins[value] = l |
| paddy@44 | 391 return nil |
| paddy@44 | 392 } |
| paddy@44 | 393 |
| paddy@57 | 394 func (m *memstore) listLogins(profile uuid.ID, num, offset int) ([]Login, error) { |
| paddy@44 | 395 m.loginLock.RLock() |
| paddy@44 | 396 defer m.loginLock.RUnlock() |
| paddy@44 | 397 ids, ok := m.profileLoginLookup[profile.String()] |
| paddy@44 | 398 if !ok { |
| paddy@44 | 399 return []Login{}, nil |
| paddy@44 | 400 } |
| paddy@44 | 401 if len(ids) > num+offset { |
| paddy@44 | 402 ids = ids[offset : num+offset] |
| paddy@44 | 403 } else if len(ids) > offset { |
| paddy@44 | 404 ids = ids[offset:] |
| paddy@44 | 405 } else { |
| paddy@44 | 406 return []Login{}, nil |
| paddy@44 | 407 } |
| paddy@44 | 408 logins := []Login{} |
| paddy@44 | 409 for _, id := range ids { |
| paddy@44 | 410 login, ok := m.logins[id] |
| paddy@44 | 411 if !ok { |
| paddy@44 | 412 continue |
| paddy@44 | 413 } |
| paddy@44 | 414 logins = append(logins, login) |
| paddy@44 | 415 } |
| paddy@44 | 416 return logins, nil |
| paddy@44 | 417 } |
| paddy@99 | 418 |
| paddy@105 | 419 // RegisterProfileHandlers adds handlers to the passed router to handle the profile endpoints, like registration and user retrieval. |
| paddy@105 | 420 func RegisterProfileHandlers(r *mux.Router, context Context) { |
| paddy@105 | 421 r.Handle("/profiles", wrap(context, CreateProfileHandler)).Methods("POST") |
| paddy@128 | 422 // BUG(paddy): We need to implement a handler that will return information about a profile or set of profiles. |
| paddy@145 | 423 r.Handle("/profiles/{id}", wrap(context, UpdateProfileHandler)).Methods("PATCH") |
| paddy@128 | 424 // 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 | 425 // BUG(paddy): We need to implement a handler that will add a login to a profile. |
| paddy@128 | 426 // 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 | 427 // BUG(paddy): We need to implement a handler that will list the logins attached to a profile. |
| paddy@105 | 428 } |
| paddy@105 | 429 |
| paddy@99 | 430 // CreateProfileHandler is an HTTP handler for registering new profiles. |
| paddy@99 | 431 func CreateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@99 | 432 scheme, ok := passphraseSchemes[CurPassphraseScheme] |
| paddy@99 | 433 if !ok { |
| paddy@105 | 434 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 435 return |
| paddy@99 | 436 } |
| paddy@99 | 437 var req newProfileRequest |
| paddy@99 | 438 errors := []requestError{} |
| paddy@99 | 439 decoder := json.NewDecoder(r.Body) |
| paddy@99 | 440 err := decoder.Decode(&req) |
| paddy@99 | 441 if err != nil { |
| paddy@105 | 442 encode(w, r, http.StatusBadRequest, invalidFormatResponse) |
| paddy@99 | 443 return |
| paddy@99 | 444 } |
| paddy@99 | 445 errors = append(errors, validateNewProfileRequest(&req)...) |
| paddy@99 | 446 if len(errors) > 0 { |
| paddy@105 | 447 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@99 | 448 return |
| paddy@99 | 449 } |
| paddy@99 | 450 passphrase, salt, err := scheme.create(req.Passphrase, context.config.iterations) |
| paddy@99 | 451 if err != nil { |
| paddy@105 | 452 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 453 return |
| paddy@99 | 454 } |
| paddy@99 | 455 profile := Profile{ |
| paddy@99 | 456 ID: uuid.NewID(), |
| paddy@99 | 457 Name: req.Name, |
| paddy@99 | 458 Passphrase: string(passphrase), |
| paddy@99 | 459 Iterations: context.config.iterations, |
| paddy@99 | 460 Salt: string(salt), |
| paddy@99 | 461 PassphraseScheme: CurPassphraseScheme, |
| paddy@99 | 462 Created: time.Now(), |
| paddy@99 | 463 LastSeen: time.Now(), |
| paddy@99 | 464 } |
| paddy@99 | 465 err = context.SaveProfile(profile) |
| paddy@99 | 466 if err != nil { |
| paddy@105 | 467 if err == ErrProfileAlreadyExists { |
| paddy@105 | 468 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/id"}}}) |
| paddy@105 | 469 return |
| paddy@105 | 470 } |
| paddy@105 | 471 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 472 return |
| paddy@99 | 473 } |
| paddy@99 | 474 logins := []Login{} |
| paddy@99 | 475 login := Login{ |
| paddy@99 | 476 Type: "email", |
| paddy@99 | 477 Value: req.Email, |
| paddy@99 | 478 Created: profile.Created, |
| paddy@99 | 479 LastUsed: profile.Created, |
| paddy@99 | 480 ProfileID: profile.ID, |
| paddy@99 | 481 } |
| paddy@99 | 482 err = context.AddLogin(login) |
| paddy@99 | 483 if err != nil { |
| paddy@105 | 484 if err == ErrLoginAlreadyExists { |
| paddy@105 | 485 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/email"}}}) |
| paddy@105 | 486 return |
| paddy@105 | 487 } |
| paddy@105 | 488 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 489 return |
| paddy@99 | 490 } |
| paddy@99 | 491 logins = append(logins, login) |
| paddy@99 | 492 if req.Username != "" { |
| paddy@99 | 493 login.Type = "username" |
| paddy@99 | 494 login.Value = req.Username |
| paddy@99 | 495 err = context.AddLogin(login) |
| paddy@99 | 496 if err != nil { |
| paddy@105 | 497 if err == ErrLoginAlreadyExists { |
| paddy@105 | 498 encode(w, r, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrConflict, Field: "/username"}}}) |
| paddy@105 | 499 return |
| paddy@105 | 500 } |
| paddy@105 | 501 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@99 | 502 return |
| paddy@99 | 503 } |
| paddy@99 | 504 logins = append(logins, login) |
| paddy@99 | 505 } |
| paddy@105 | 506 resp := response{ |
| paddy@105 | 507 Logins: logins, |
| paddy@105 | 508 Profiles: []Profile{profile}, |
| paddy@105 | 509 } |
| paddy@105 | 510 encode(w, r, http.StatusCreated, resp) |
| paddy@99 | 511 // TODO(paddy): should we kick off the email validation flow? |
| paddy@99 | 512 } |
| paddy@145 | 513 |
| paddy@145 | 514 func UpdateProfileHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@145 | 515 errors := []requestError{} |
| paddy@145 | 516 vars := mux.Vars(r) |
| paddy@145 | 517 if vars["id"] == "" { |
| paddy@145 | 518 errors = append(errors, requestError{Slug: requestErrMissing, Param: "id"}) |
| paddy@145 | 519 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 520 return |
| paddy@145 | 521 } |
| paddy@145 | 522 id, err := uuid.Parse(vars["id"]) |
| paddy@145 | 523 if err != nil { |
| paddy@145 | 524 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 525 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 526 return |
| paddy@145 | 527 } |
| paddy@145 | 528 username, password, ok := r.BasicAuth() |
| paddy@145 | 529 if !ok { |
| paddy@145 | 530 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 531 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@145 | 532 return |
| paddy@145 | 533 } |
| paddy@145 | 534 profile, err := authenticate(username, password, context) |
| paddy@145 | 535 if err != nil { |
| paddy@145 | 536 if isAuthError(err) { |
| paddy@145 | 537 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 538 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@145 | 539 } else { |
| paddy@145 | 540 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@145 | 541 encode(w, r, http.StatusInternalServerError, response{Errors: errors}) |
| paddy@145 | 542 } |
| paddy@145 | 543 return |
| paddy@145 | 544 } |
| paddy@145 | 545 if !profile.ID.Equal(id) { |
| paddy@145 | 546 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@145 | 547 encode(w, r, http.StatusForbidden, response{Errors: errors}) |
| paddy@145 | 548 return |
| paddy@145 | 549 } |
| paddy@145 | 550 var req ProfileChange |
| paddy@145 | 551 decoder := json.NewDecoder(r.Body) |
| paddy@145 | 552 err = decoder.Decode(&req) |
| paddy@145 | 553 if err != nil { |
| paddy@145 | 554 encode(w, r, http.StatusBadRequest, invalidFormatResponse) |
| paddy@145 | 555 return |
| paddy@145 | 556 } |
| paddy@145 | 557 req.Iterations = nil |
| paddy@145 | 558 req.Salt = nil |
| paddy@145 | 559 req.PassphraseScheme = nil |
| paddy@145 | 560 req.Compromised = nil // BUG(paddy): Need a way for admins to mark accounts as compromised |
| paddy@145 | 561 req.LockedUntil = nil |
| paddy@145 | 562 req.LastSeen = nil |
| paddy@145 | 563 if req.Passphrase != nil { |
| paddy@145 | 564 if len(*req.Passphrase) < MinPassphraseLength { |
| paddy@145 | 565 errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/passphrase"}) |
| paddy@145 | 566 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 567 return |
| paddy@145 | 568 } |
| paddy@145 | 569 if len(*req.Passphrase) > MaxPassphraseLength { |
| paddy@145 | 570 errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/passphrase"}) |
| paddy@145 | 571 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@145 | 572 return |
| paddy@145 | 573 } |
| paddy@145 | 574 iterations := context.config.iterations |
| paddy@145 | 575 scheme, ok := passphraseSchemes[CurPassphraseScheme] |
| paddy@145 | 576 if !ok { |
| paddy@145 | 577 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 578 return |
| paddy@145 | 579 } |
| paddy@145 | 580 curScheme := CurPassphraseScheme |
| paddy@145 | 581 req.PassphraseScheme = &curScheme |
| paddy@145 | 582 passphrase, salt, err := scheme.create(*req.Passphrase, iterations) |
| paddy@145 | 583 if err != nil { |
| paddy@145 | 584 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 585 return |
| paddy@145 | 586 } |
| paddy@145 | 587 req.Passphrase = &passphrase |
| paddy@145 | 588 req.Salt = &salt |
| paddy@145 | 589 req.Iterations = &iterations |
| paddy@145 | 590 } |
| paddy@145 | 591 if req.PassphraseReset != nil { |
| paddy@145 | 592 now := time.Now() |
| paddy@145 | 593 req.PassphraseResetCreated = &now |
| paddy@145 | 594 } |
| paddy@145 | 595 err = req.Validate() |
| paddy@145 | 596 if err != nil { |
| paddy@145 | 597 var status int |
| paddy@145 | 598 var resp response |
| paddy@145 | 599 switch err { |
| paddy@145 | 600 case ErrEmptyChange: |
| paddy@145 | 601 resp.Profiles = []Profile{profile} |
| paddy@145 | 602 status = http.StatusOK |
| paddy@145 | 603 default: |
| paddy@145 | 604 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@145 | 605 resp.Errors = errors |
| paddy@145 | 606 status = http.StatusInternalServerError |
| paddy@145 | 607 } |
| paddy@145 | 608 encode(w, r, status, resp) |
| paddy@145 | 609 return |
| paddy@145 | 610 } |
| paddy@145 | 611 err = context.UpdateProfile(id, req) |
| paddy@145 | 612 if err != nil { |
| paddy@145 | 613 if err == ErrProfileNotFound { |
| paddy@145 | 614 errors = append(errors, requestError{Slug: requestErrNotFound}) |
| paddy@145 | 615 encode(w, r, http.StatusNotFound, response{Errors: errors}) |
| paddy@145 | 616 return |
| paddy@145 | 617 } |
| paddy@145 | 618 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@145 | 619 return |
| paddy@145 | 620 } |
| paddy@145 | 621 profile.ApplyChange(req) |
| paddy@145 | 622 encode(w, r, http.StatusOK, response{Profiles: []Profile{profile}}) |
| paddy@145 | 623 return |
| paddy@145 | 624 } |