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