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