From 5cfc9bf2f95effac4f837fbdfd3ec598ad05f02c Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 29 Jul 2021 18:45:22 +0200 Subject: [PATCH] Disable the user account after 10 failed password attempts --- docs/content/doc/usage/errors.md | 2 ++ pkg/routes/api/v1/login.go | 15 +++++----- pkg/user/error.go | 27 +++++++++++++++++ pkg/user/notifications.go | 25 ++++++++++++++++ pkg/user/totp.go | 50 +++++++++++++++++++++++++++++++- pkg/user/user.go | 10 +++++++ pkg/user/user_password_reset.go | 3 +- 7 files changed, 122 insertions(+), 10 deletions(-) diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 4f4019afb..f5325ddd7 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -40,6 +40,8 @@ This document describes the different errors Vikunja can return. | 1016 | 412 | Totp is not enabled for this user. | | 1017 | 412 | The provided Totp passcode is invalid. | | 1018 | 412 | The provided user avatar provider type setting is invalid. | +| 1019 | 412 | No openid email address was provided. | +| 1020 | 412 | This user account is disabled. | ## Validation diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 792f137a5..cbaaf91df 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -20,10 +20,8 @@ import ( "net/http" "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" - "code.vikunja.io/api/pkg/notifications" user2 "code.vikunja.io/api/pkg/user" "code.vikunja.io/web/handler" @@ -59,6 +57,11 @@ func Login(c echo.Context) error { return handler.HandleHTTPError(err, c) } + if user.Status == user2.StatusDisabled { + _ = s.Rollback() + return handler.HandleHTTPError(&user2.ErrAccountDisabled{UserID: user.ID}, c) + } + totpEnabled, err := user2.TOTPEnabledForUser(s, user) if err != nil { _ = s.Rollback() @@ -67,6 +70,7 @@ func Login(c echo.Context) error { if totpEnabled { if u.TOTPPasscode == "" { + _ = s.Rollback() return handler.HandleHTTPError(user2.ErrInvalidTOTPPasscode{}, c) } @@ -76,12 +80,7 @@ func Login(c echo.Context) error { }) if err != nil { if user2.IsErrInvalidTOTPPasscode(err) { - log.Errorf("Invalid TOTP credentials provided for user %d", user.ID) - - err2 := notifications.Notify(user, &user2.InvalidTOTPNotification{User: user}) - if err2 != nil { - log.Errorf("Could not send failed TOTP notification to user %d: %s", user.ID, err2) - } + user2.HandleFailedTOTPAuth(s, user) } _ = s.Rollback() return handler.HandleHTTPError(err, c) diff --git a/pkg/user/error.go b/pkg/user/error.go index 6480d5ffe..089eef1b9 100644 --- a/pkg/user/error.go +++ b/pkg/user/error.go @@ -425,3 +425,30 @@ func (err *ErrNoOpenIDEmailProvided) HTTPError() web.HTTPError { Message: "No email address available. Please make sure the openid provider publicly provides an email address for your account.", } } + +// ErrAccountDisabled represents a "AccountDisabled" kind of error. +type ErrAccountDisabled struct { + UserID int64 +} + +// IsErrAccountDisabled checks if an error is a ErrAccountDisabled. +func IsErrAccountDisabled(err error) bool { + _, ok := err.(*ErrAccountDisabled) + return ok +} + +func (err *ErrAccountDisabled) Error() string { + return "Account is disabled" +} + +// ErrCodeAccountDisabled holds the unique world-error code of this error +const ErrCodeAccountDisabled = 1020 + +// HTTPError holds the http error description +func (err *ErrAccountDisabled) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusPreconditionFailed, + Code: ErrCodeAccountDisabled, + Message: "This account is disabled. Check your emails or ask your administrator.", + } +} diff --git a/pkg/user/notifications.go b/pkg/user/notifications.go index ba0a9bd80..3fecbab43 100644 --- a/pkg/user/notifications.go +++ b/pkg/user/notifications.go @@ -135,3 +135,28 @@ func (n *InvalidTOTPNotification) ToDB() interface{} { func (n *InvalidTOTPNotification) Name() string { return "totp.invalid" } + +// PasswordAccountLockedAfterInvalidTOTOPNotification represents a PasswordAccountLockedAfterInvalidTOTOPNotification notification +type PasswordAccountLockedAfterInvalidTOTOPNotification struct { + User *User +} + +// ToMail returns the mail notification for PasswordAccountLockedAfterInvalidTOTOPNotification +func (n *PasswordAccountLockedAfterInvalidTOTOPNotification) ToMail() *notifications.Mail { + return notifications.NewMail(). + Subject("We've disabled your account on Vikunja"). + Greeting("Hi " + n.User.GetName() + ","). + Line("Someone tried to log in with your credentials but failed to provide a valid TOTP passcode."). + Line("After 10 failed attempts, we've disabled your account and reset your password. To set a new one, follow the instructions in the reset email we just sent you."). + Line("If you did not receive an email with reset instructions, you can always request a new one at [" + config.ServiceFrontendurl.GetString() + "get-password-reset](" + config.ServiceFrontendurl.GetString() + "get-password-reset).") +} + +// ToDB returns the PasswordAccountLockedAfterInvalidTOTOPNotification notification in a format which can be saved in the db +func (n *PasswordAccountLockedAfterInvalidTOTOPNotification) ToDB() interface{} { + return nil +} + +// Name returns the name of the notification +func (n *PasswordAccountLockedAfterInvalidTOTOPNotification) Name() string { + return "password.account.locked.after.invalid.totop" +} diff --git a/pkg/user/totp.go b/pkg/user/totp.go index 34f42c27a..a12e72324 100644 --- a/pkg/user/totp.go +++ b/pkg/user/totp.go @@ -18,12 +18,17 @@ package user import ( "image" + "strconv" - "xorm.io/xorm" + "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/notifications" + "github.com/pquerna/otp" "github.com/pquerna/otp/totp" + "xorm.io/xorm" ) // TOTP holds a user's totp setting in the database. @@ -149,3 +154,46 @@ 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) + + 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) + } + + key := user.GetFailedTOTPAttemptsKey() + err = keyvalue.IncrBy(key, 1) + if err != nil { + log.Errorf("Could not increase failed TOTP attempts for user %d: %s", user.ID, err) + } + + a, _, err := keyvalue.Get(key) + if err != nil { + log.Errorf("Could get failed TOTP attempts for user %d: %s", user.ID, err) + } + attempts := a.(int64) + if attempts > 10 { + log.Infof("Blocking user account %d after 10 failed TOTP password attempts", user.ID) + 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) + } + 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) + } + err = user.SetStatus(s, StatusDisabled) + if err != nil { + log.Errorf("Could not disable user %d: %s", user.ID, err) + } + } +} diff --git a/pkg/user/user.go b/pkg/user/user.go index 5ade58ba0..4e9ba5b22 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -458,3 +458,13 @@ func UpdateUserPassword(s *xorm.Session, user *User, newPassword string) (err er return err } + +// SetStatus sets a users status in the database +func (u *User) SetStatus(s *xorm.Session, status Status) (err error) { + u.Status = status + _, err = s. + Where("id = ?", u.ID). + Cols("status"). + Update(u) + return +} diff --git a/pkg/user/user_password_reset.go b/pkg/user/user_password_reset.go index b03a7807a..277d0bb78 100644 --- a/pkg/user/user_password_reset.go +++ b/pkg/user/user_password_reset.go @@ -67,8 +67,9 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) { return } + user.Status = StatusActive _, err = s. - Cols("password"). + Cols("password", "status"). Where("id = ?", user.ID). Update(user) if err != nil {