Disable the user account after 10 failed password attempts
continuous-integration/drone/push Build is passing Details

This commit is contained in:
kolaente 2021-07-29 18:45:22 +02:00
parent 3572ac4b82
commit 5cfc9bf2f9
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
7 changed files with 122 additions and 10 deletions

View File

@ -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

View File

@ -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)

View File

@ -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.",
}
}

View File

@ -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"
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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 {