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