diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index afa9140f..2ce22b3d 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -92,6 +92,9 @@ func Login(c echo.Context) error { if err := keyvalue.Del(user.GetFailedTOTPAttemptsKey()); err != nil { return err } + if err := keyvalue.Del(user.GetFailedPasswordAttemptsKey()); err != nil { + return err + } if err := s.Commit(); err != nil { _ = s.Rollback() diff --git a/pkg/user/notifications.go b/pkg/user/notifications.go index 3fecbab4..c57eb40e 100644 --- a/pkg/user/notifications.go +++ b/pkg/user/notifications.go @@ -160,3 +160,29 @@ func (n *PasswordAccountLockedAfterInvalidTOTOPNotification) ToDB() interface{} func (n *PasswordAccountLockedAfterInvalidTOTOPNotification) Name() string { return "password.account.locked.after.invalid.totop" } + +// FailedLoginAttemptNotification represents a FailedLoginAttemptNotification notification +type FailedLoginAttemptNotification struct { + User *User +} + +// ToMail returns the mail notification for FailedLoginAttemptNotification +func (n *FailedLoginAttemptNotification) ToMail() *notifications.Mail { + return notifications.NewMail(). + Subject("Someone just tried to login to your Vikunja account, but failed to provide a correct password"). + Greeting("Hi "+n.User.GetName()+","). + Line("Someone just tried to log in into your account with a wrong password three times in a row."). + Line("If this was not you, this could be someone else trying to break into your account."). + Line("To enhance the security of you account you may want to set a stronger password or enable TOTP authentication in the settings:"). + Action("Go to settings", config.ServiceFrontendurl.GetString()+"user/settings") +} + +// ToDB returns the FailedLoginAttemptNotification notification in a format which can be saved in the db +func (n *FailedLoginAttemptNotification) ToDB() interface{} { + return nil +} + +// Name returns the name of the notification +func (n *FailedLoginAttemptNotification) Name() string { + return "failed.login.attempt" +} diff --git a/pkg/user/totp.go b/pkg/user/totp.go index fa164415..80952dca 100644 --- a/pkg/user/totp.go +++ b/pkg/user/totp.go @@ -18,12 +18,10 @@ package user import ( "image" - "strconv" - - "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/notifications" "github.com/pquerna/otp" @@ -155,10 +153,6 @@ func GetTOTPQrCodeForUser(s *xorm.Session, user *User) (qrcode image.Image, err return key.Image(300, 300) } -func (u *User) GetFailedTOTPAttemptsKey() string { - return "failed_totp_attempts_" + strconv.FormatInt(u.ID, 10) -} - // HandleFailedTOTPAuth handles informing the user of failed TOTP attempts and blocking the account after 10 attempts func HandleFailedTOTPAuth(s *xorm.Session, user *User) { log.Errorf("Invalid TOTP credentials provided for user %d", user.ID) @@ -167,11 +161,13 @@ func HandleFailedTOTPAuth(s *xorm.Session, user *User) { err := keyvalue.IncrBy(key, 1) if err != nil { log.Errorf("Could not increase failed TOTP attempts for user %d: %s", user.ID, err) + return } a, _, err := keyvalue.Get(key) if err != nil { log.Errorf("Could get failed TOTP attempts for user %d: %s", user.ID, err) + return } attempts := a.(int64) @@ -179,6 +175,7 @@ func HandleFailedTOTPAuth(s *xorm.Session, user *User) { err = notifications.Notify(user, &InvalidTOTPNotification{User: user}) if err != nil { log.Errorf("Could not send failed TOTP notification to user %d: %s", user.ID, err) + return } } @@ -190,12 +187,14 @@ func HandleFailedTOTPAuth(s *xorm.Session, user *User) { err = RequestUserPasswordResetToken(s, user) if err != nil { log.Errorf("Could not reset password of user %d after 10 failed TOTP attempts: %s", user.ID, err) + return } err = notifications.Notify(user, &PasswordAccountLockedAfterInvalidTOTOPNotification{ User: user, }) if err != nil { log.Errorf("Could send password information mail to user %d after 10 failed TOTP attempts: %s", user.ID, err) + return } err = user.SetStatus(s, StatusDisabled) if err != nil { diff --git a/pkg/user/user.go b/pkg/user/user.go index 4e9ba5b2..4f4cdd78 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -20,18 +20,20 @@ import ( "errors" "fmt" "reflect" + "strconv" "time" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/db" - - "xorm.io/xorm" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/keyvalue" + "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/web" "github.com/golang-jwt/jwt" "github.com/labstack/echo/v4" "golang.org/x/crypto/bcrypt" + "xorm.io/xorm" ) // Login Object to recive user credentials in JSON format @@ -146,6 +148,14 @@ func (u *User) GetNameAndFromEmail() string { return u.GetName() + " via Vikunja <" + config.MailerFromEmail.GetString() + ">" } +func (u *User) GetFailedTOTPAttemptsKey() string { + return "failed_totp_attempts_" + strconv.FormatInt(u.ID, 10) +} + +func (u *User) GetFailedPasswordAttemptsKey() string { + return "failed_password_attempts_" + strconv.FormatInt(u.ID, 10) +} + // GetFromAuth returns a user object from a web.Auth object and returns an error if the underlying type // is not a user object func GetFromAuth(a web.Auth) (*User, error) { @@ -302,12 +312,48 @@ func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) { // Check the users password err = CheckUserPassword(user, u.Password) if err != nil { + if IsErrWrongUsernameOrPassword(err) { + handleFailedPassword(user) + } return nil, err } return user, nil } +func handleFailedPassword(user *User) { + key := user.GetFailedPasswordAttemptsKey() + err := keyvalue.IncrBy(key, 1) + if err != nil { + log.Errorf("Could not set failed password attempts: %s", err) + return + } + + a, _, err := keyvalue.Get(key) + if err != nil { + log.Errorf("Could get failed password attempts for user %d: %s", user.ID, err) + return + } + attempts := a.(int64) + if attempts != 3 { + return + } + + err = notifications.Notify(user, &FailedLoginAttemptNotification{ + User: user, + }) + if err != nil { + log.Errorf("Could not send invalid password mail to user: %s", err) + return + } + + err = keyvalue.Del(key) + if err != nil { + log.Errorf("Could not remove failed password attempts: %s", err) + return + } +} + // CheckUserPassword checks and verifies a user's password. The user object needs to contain the hashed password from the database. func CheckUserPassword(user *User, password string) error { err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))