Password reset rate limiting & existing token invalidation
This commit is contained in:
+153
-48
@@ -64,10 +64,17 @@ const (
|
||||
|
||||
passwordMinLength = 8
|
||||
passwordMaxLength = 256
|
||||
|
||||
tokenReqMaxCount = 5
|
||||
tokenReqMaxWindow = time.Minute * 15
|
||||
)
|
||||
|
||||
var (
|
||||
reEmail = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
||||
|
||||
oneTokenPerUser = map[string]bool{
|
||||
credentialsTypeResetPasswordToken: true,
|
||||
}
|
||||
)
|
||||
|
||||
func defaultProviderValidator(provider string) error {
|
||||
@@ -1007,67 +1014,55 @@ func (svc auth) procLogin(ctx context.Context, s store.Storer, u *types.User, c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc auth) loadUserFromToken(ctx context.Context, token, kind string) (u *types.User, err error) {
|
||||
// Loads user from token and removes that token right after
|
||||
func (svc auth) loadUserFromToken(ctx context.Context, token, kind string) (u *types.User, _ error) {
|
||||
var (
|
||||
aam = &authActionProps{
|
||||
credentials: &types.Credentials{Kind: kind},
|
||||
}
|
||||
)
|
||||
|
||||
credentialsID, credentials := svc.validateToken(token)
|
||||
if credentialsID == 0 {
|
||||
return nil, AuthErrInvalidToken(aam)
|
||||
}
|
||||
return u, svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) {
|
||||
credentialsID, credentials := validateToken(token)
|
||||
if credentialsID == 0 {
|
||||
return AuthErrInvalidToken(aam)
|
||||
}
|
||||
|
||||
c, err := store.LookupCredentialsByID(ctx, svc.store, credentialsID)
|
||||
if errors.IsNotFound(err) {
|
||||
return nil, AuthErrInvalidToken(aam)
|
||||
}
|
||||
c, err := store.LookupCredentialsByID(ctx, s, credentialsID)
|
||||
if errors.IsNotFound(err) {
|
||||
return AuthErrInvalidToken(aam)
|
||||
}
|
||||
|
||||
aam.setCredentials(c)
|
||||
aam.setCredentials(c)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = store.DeleteCredentialsByID(ctx, svc.store, c.ID); err != nil {
|
||||
return
|
||||
}
|
||||
if err = store.DeleteCredentialsByID(ctx, s, c.ID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !c.Valid() || c.Credentials != credentials {
|
||||
return nil, AuthErrInvalidToken(aam)
|
||||
}
|
||||
if !c.Valid() || c.Credentials != credentials {
|
||||
return AuthErrInvalidToken(aam)
|
||||
}
|
||||
|
||||
u, err = store.LookupUserByID(ctx, svc.store, c.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err = store.LookupUserByID(ctx, s, c.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aam.setUser(u)
|
||||
aam.setUser(u)
|
||||
|
||||
// context will be updated with new identity
|
||||
// in the caller fn
|
||||
// context will be updated with new identity
|
||||
// in the caller fn
|
||||
|
||||
if !u.Valid() {
|
||||
return nil, AuthErrInvalidCredentials(aam)
|
||||
}
|
||||
if !u.Valid() {
|
||||
return AuthErrInvalidCredentials(aam)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (svc auth) validateToken(token string) (ID uint64, credentials string) {
|
||||
// Token = <32 random chars><credentials-id>
|
||||
if len(token) <= credentialsTokenLength {
|
||||
return
|
||||
}
|
||||
|
||||
ID, _ = strconv.ParseUint(token[credentialsTokenLength:], 10, 64)
|
||||
if ID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
credentials = token[:credentialsTokenLength]
|
||||
return
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Generates & stores user token
|
||||
@@ -1081,7 +1076,35 @@ func (svc auth) createUserToken(ctx context.Context, u *types.User, kind string)
|
||||
}
|
||||
)
|
||||
|
||||
err = func() error {
|
||||
err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) {
|
||||
if u == nil || u.ID == 0 {
|
||||
return AuthErrGeneric()
|
||||
}
|
||||
|
||||
// Rate limit requests
|
||||
cc, _, err := store.SearchCredentials(ctx, s, types.CredentialsFilter{
|
||||
OwnerID: u.ID,
|
||||
Kind: kind,
|
||||
|
||||
// we want to count deleted tokens as well
|
||||
Deleted: filter.StateInclusive,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// gt/eq since this current request is not yet stored
|
||||
if err = svc.checkTokenRate(cc, tokenReqMaxWindow, tokenReqMaxCount); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// removes expired and soft-deleted tokens
|
||||
// and enforces one-token-per-user rule
|
||||
if err = svc.cleanupCredentials(ctx, s, cc); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case credentialsTypeMFAEmailOTP:
|
||||
expSec := svc.settings.Auth.MultiFactor.EmailOTP.Expires
|
||||
@@ -1118,7 +1141,7 @@ func (svc auth) createUserToken(ctx context.Context, u *types.User, kind string)
|
||||
ExpiresAt: &expiresAt,
|
||||
}
|
||||
|
||||
err = store.CreateCredentials(ctx, svc.store, c)
|
||||
err = store.CreateCredentials(ctx, s, c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1134,11 +1157,78 @@ func (svc auth) createUserToken(ctx context.Context, u *types.User, kind string)
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
})
|
||||
|
||||
return token, svc.recordAction(ctx, aam, AuthActionIssueToken, err)
|
||||
}
|
||||
|
||||
// checks existing tokens and ensure that the creation rate is within limits
|
||||
func (svc auth) checkTokenRate(cc types.CredentialsSet, window time.Duration, max int) error {
|
||||
if len(cc) == 0 || window == 0 || max == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
cutoff = now().Add(window * -1)
|
||||
count = 0
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
if c.CreatedAt.Before(cutoff) {
|
||||
// skip tokens created before cutoff
|
||||
continue
|
||||
}
|
||||
|
||||
count++
|
||||
|
||||
if count > max {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if count > max {
|
||||
return AuthErrRateLimitExceeded()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc auth) cleanupCredentials(ctx context.Context, s store.Credentials, cc types.CredentialsSet) (err error) {
|
||||
var (
|
||||
update types.CredentialsSet
|
||||
remove types.CredentialsSet
|
||||
)
|
||||
|
||||
for _, c := range cc {
|
||||
switch {
|
||||
case oneTokenPerUser[c.Kind]:
|
||||
// if token type is shortlisted in one-token-per-user
|
||||
// mark all existing tokens as deleted if to
|
||||
//
|
||||
// only want to mark them as deleted ad
|
||||
c.DeletedAt = now()
|
||||
update = append(update, c)
|
||||
|
||||
case false, // just a placeholder
|
||||
c.DeletedAt.Add(tokenReqMaxWindow).Before(*now()),
|
||||
c.ExpiresAt.Before(*now()):
|
||||
// schedule all soft-deleted and expired token
|
||||
// for removal
|
||||
remove = append(remove, c)
|
||||
}
|
||||
}
|
||||
|
||||
if err = store.UpdateCredentials(ctx, s, update...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = store.DeleteCredentials(ctx, s, remove...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically promotes user to super-administrator if it is the first non-system user in the database
|
||||
func (svc auth) autoPromote(ctx context.Context, u *types.User) (err error) {
|
||||
var (
|
||||
@@ -1539,3 +1629,18 @@ func (svc auth) RemoveAccessTokens(ctx context.Context, user *types.User) error
|
||||
svc.store.DeleteAuthOA2TokenByUserID(ctx, user.ID),
|
||||
)
|
||||
}
|
||||
|
||||
func validateToken(token string) (ID uint64, credentials string) {
|
||||
// Token = <32 random chars><credentials-id>
|
||||
if len(token) <= credentialsTokenLength {
|
||||
return
|
||||
}
|
||||
|
||||
ID, _ = strconv.ParseUint(token[credentialsTokenLength:], 10, 64)
|
||||
if ID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
credentials = token[:credentialsTokenLength]
|
||||
return
|
||||
}
|
||||
|
||||
Generated
+36
@@ -1748,6 +1748,42 @@ func AuthErrInvalidEmailOTP(mm ...*authActionProps) *errors.Error {
|
||||
return e
|
||||
}
|
||||
|
||||
// AuthErrRateLimitExceeded returns "system:auth.rateLimitExceeded" as *errors.Error
|
||||
//
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func AuthErrRateLimitExceeded(mm ...*authActionProps) *errors.Error {
|
||||
var p = &authActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
p.Format("rate limit exceeded", nil),
|
||||
|
||||
errors.Meta("type", "rateLimitExceeded"),
|
||||
errors.Meta("resource", "system:auth"),
|
||||
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta(authLogMetaKey{}, "rate limit exceeded for {{user}}"),
|
||||
errors.Meta(authPropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "system"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "auth.errors.rateLimitExceeded"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// AuthErrMaxUserLimitReached returns "system:auth.maxUserLimitReached" as *errors.Error
|
||||
//
|
||||
//
|
||||
|
||||
@@ -203,6 +203,10 @@ errors:
|
||||
message: "invalid code"
|
||||
severity: warning
|
||||
|
||||
- error: rateLimitExceeded
|
||||
message: "rate limit exceeded"
|
||||
log: "rate limit exceeded for {{user}}"
|
||||
|
||||
- error: maxUserLimitReached
|
||||
message: "you have reached your user limit, contact your Corteza administrator"
|
||||
severity: warning
|
||||
|
||||
+108
-3
@@ -287,6 +287,112 @@ func TestAuth_InternalLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_createUserToken(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
ctx = context.Background()
|
||||
|
||||
validUser = &types.User{Email: "valid@test.cortezaproject.org", ID: nextID(), CreatedAt: *now(), EmailConfirmed: true}
|
||||
|
||||
tests = []struct {
|
||||
name string
|
||||
user *types.User
|
||||
kind string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
"no user",
|
||||
nil,
|
||||
"",
|
||||
AuthErrGeneric(),
|
||||
},
|
||||
{
|
||||
"zero ID",
|
||||
&types.User{},
|
||||
"",
|
||||
AuthErrGeneric(),
|
||||
},
|
||||
{
|
||||
"valid user",
|
||||
validUser,
|
||||
credentialsTypeResetPasswordToken,
|
||||
nil,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
svc := makeMockAuthService()
|
||||
req.NoError(svc.store.TruncateUsers(ctx))
|
||||
req.NoError(svc.store.TruncateCredentials(ctx))
|
||||
req.NoError(store.CreateUser(ctx, svc.store, validUser))
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req = require.New(t)
|
||||
|
||||
var (
|
||||
token, err = svc.createUserToken(ctx, tt.user, tt.kind)
|
||||
)
|
||||
|
||||
if tt.err == nil {
|
||||
req.NoError(err)
|
||||
req.NotEmpty(token)
|
||||
} else {
|
||||
req.EqualError(err, tt.err.Error())
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that existing password reset tokens are invalidated AND rate limiting kicks in
|
||||
func TestAuth_multiCreateUserTokenForPasswordReset(t *testing.T) {
|
||||
var (
|
||||
err error
|
||||
pToken, token string
|
||||
|
||||
svc = makeMockAuthService()
|
||||
|
||||
req = require.New(t)
|
||||
ctx = context.Background()
|
||||
|
||||
validUser = &types.User{Email: "valid@test.cortezaproject.org", ID: nextID(), CreatedAt: *now(), EmailConfirmed: true}
|
||||
|
||||
// load credentials from token
|
||||
t2c = func(token string) *types.Credentials {
|
||||
id, _ := validateToken(token)
|
||||
req.NotZero(id)
|
||||
c, err := store.LookupCredentialsByID(ctx, svc.store, id)
|
||||
req.NoError(err)
|
||||
return c
|
||||
}
|
||||
)
|
||||
|
||||
req.NoError(svc.store.TruncateUsers(ctx))
|
||||
req.NoError(svc.store.TruncateCredentials(ctx))
|
||||
req.NoError(store.CreateUser(ctx, svc.store, validUser))
|
||||
|
||||
for try := 0; try <= tokenReqMaxCount+1; try++ {
|
||||
token, err = svc.createUserToken(ctx, validUser, credentialsTypeResetPasswordToken)
|
||||
t.Log("got token", token)
|
||||
|
||||
if try == tokenReqMaxCount+1 {
|
||||
t.Log("rate limiting should kicked in")
|
||||
req.EqualError(err, AuthErrRateLimitExceeded().Error())
|
||||
} else {
|
||||
if try > 0 {
|
||||
t.Log("checking if previous token", pToken, "is deleted")
|
||||
req.NotNil(t2c(pToken).DeletedAt)
|
||||
}
|
||||
|
||||
req.NoError(err)
|
||||
req.Nil(t2c(token).DeletedAt)
|
||||
pToken = token
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func Test_auth_checkPassword(t *testing.T) {
|
||||
plainPassword := " ... plain password ... "
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
||||
@@ -347,7 +453,7 @@ func Test_auth_checkPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_auth_validateToken(t *testing.T) {
|
||||
func TestValidateToken(t *testing.T) {
|
||||
type args struct {
|
||||
token string
|
||||
}
|
||||
@@ -380,8 +486,7 @@ func Test_auth_validateToken(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := auth{}
|
||||
gotID, gotCredentials := svc.validateToken(tt.args.token)
|
||||
gotID, gotCredentials := validateToken(tt.args.token)
|
||||
|
||||
if gotID != tt.wantID {
|
||||
t.Errorf("auth.validateToken() gotID = %v, want %v", gotID, tt.wantID)
|
||||
|
||||
Reference in New Issue
Block a user