satellite/console: PendingBotVerification status for users with high captcha score

Added new captcha score cutoff threshold config value (default is 0.8).
Added new user status PendingBotVerification which is applied right after account activation if signup captcha score is above threshold.
Restricted project creation/joining if user's status is PendingBotVerification.

Issue:
https://github.com/storj/storj-private/issues/503

Change-Id: I9fa9932ffad48ea4f5ce8235178bd4af45a1bc48
This commit is contained in:
Vitalii 2023-11-28 17:00:06 +02:00
parent fb31761bad
commit 594b0933f1
6 changed files with 108 additions and 17 deletions

View File

@ -37,8 +37,10 @@ type Config struct {
// CaptchaConfig contains configurations for login/registration captcha system. // CaptchaConfig contains configurations for login/registration captcha system.
type CaptchaConfig struct { type CaptchaConfig struct {
Login MultiCaptchaConfig `json:"login"` FlagBotsEnabled bool `help:"indicates if flagging bot accounts is enabled" default:"false"`
Registration MultiCaptchaConfig `json:"registration"` ScoreCutoffThreshold float64 `help:"bad captcha score threshold which is used to prevent bot user activity" default:"0.8"`
Login MultiCaptchaConfig `json:"login"`
Registration MultiCaptchaConfig `json:"registration"`
} }
// MultiCaptchaConfig contains configurations for Recaptcha and Hcaptcha systems. // MultiCaptchaConfig contains configurations for Recaptcha and Hcaptcha systems.

View File

@ -277,6 +277,11 @@ func (p *Projects) CreateProject(w http.ResponseWriter, r *http.Request) {
project, err := p.service.CreateProject(ctx, payload) project, err := p.service.CreateProject(ctx, payload)
if err != nil { if err != nil {
if console.ErrBotUser.Has(err) {
p.serveJSONError(ctx, w, http.StatusForbidden, err)
return
}
if console.ErrUnauthorized.Has(err) { if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err) p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return return
@ -663,6 +668,8 @@ func (p *Projects) RespondToInvitation(w http.ResponseWriter, r *http.Request) {
status = http.StatusNotFound status = http.StatusNotFound
case console.ErrValidation.Has(err): case console.ErrValidation.Has(err):
status = http.StatusBadRequest status = http.StatusBadRequest
case console.ErrBotUser.Has(err):
status = http.StatusForbidden
} }
p.serveJSONError(ctx, w, status, err) p.serveJSONError(ctx, w, status, err)
} }

View File

@ -84,6 +84,7 @@ const (
projInviteDoesntExistErrMsg = "An invitation for '%s' does not exist" projInviteDoesntExistErrMsg = "An invitation for '%s' does not exist"
newInviteLimitErrMsg = "Only one new invitation can be sent at a time" newInviteLimitErrMsg = "Only one new invitation can be sent at a time"
paidTierInviteErrMsg = "Only paid tier users can invite project members" paidTierInviteErrMsg = "Only paid tier users can invite project members"
contactSupportErrMsg = "Please contact support"
) )
var ( var (
@ -162,6 +163,9 @@ var (
// ErrNotPaidTier occurs when a user must be paid tier in order to complete an operation. // ErrNotPaidTier occurs when a user must be paid tier in order to complete an operation.
ErrNotPaidTier = errs.Class("user is not paid tier") ErrNotPaidTier = errs.Class("user is not paid tier")
// ErrBotUser occurs when a user must be verified by admin first in order to complete operation.
ErrBotUser = errs.Class("user has to be verified by admin first")
) )
// Service is handling accounts related logic. // Service is handling accounts related logic.
@ -1047,6 +1051,10 @@ func (s *Service) SetAccountActive(ctx context.Context, user *User) (err error)
defer mon.Task()(&ctx)(&err) defer mon.Task()(&ctx)(&err)
status := Active status := Active
if s.config.Captcha.FlagBotsEnabled && user.SignupCaptcha != nil && *user.SignupCaptcha >= s.config.Captcha.ScoreCutoffThreshold {
status = PendingBotVerification
}
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{ err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
Status: &status, Status: &status,
}) })
@ -1203,16 +1211,28 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (response *TokenI
} }
} }
user, unverified, err := s.store.Users().GetByEmailWithUnverified(ctx, request.Email) user, nonActiveUsers, err := s.store.Users().GetByEmailWithUnverified(ctx, request.Email)
if user == nil { if user == nil {
if len(unverified) > 0 { isBotAccount := false
mon.Counter("login_email_unverified").Inc(1) //mon:locked for _, usr := range nonActiveUsers {
s.auditLog(ctx, "login: failed email unverified", nil, request.Email) if usr.Status == PendingBotVerification {
} else { isBotAccount = true
mon.Counter("login_email_invalid").Inc(1) //mon:locked botAccount := usr
s.auditLog(ctx, "login: failed invalid email", nil, request.Email) user = &botAccount
break
}
}
if !isBotAccount {
if len(nonActiveUsers) > 0 {
mon.Counter("login_email_unverified").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed email unverified", nil, request.Email)
} else {
mon.Counter("login_email_invalid").Inc(1) //mon:locked
s.auditLog(ctx, "login: failed invalid email", nil, request.Email)
}
return nil, ErrLoginCredentials.New(credentialsErrMsg)
} }
return nil, ErrLoginCredentials.New(credentialsErrMsg)
} }
now := time.Now() now := time.Now()
@ -1763,6 +1783,10 @@ func (s *Service) CreateProject(ctx context.Context, projectInfo UpsertProjectIn
return nil, Error.Wrap(err) return nil, Error.Wrap(err)
} }
if user.Status == PendingBotVerification {
return nil, ErrBotUser.New(contactSupportErrMsg)
}
currentProjectCount, err := s.checkProjectLimit(ctx, user.ID) currentProjectCount, err := s.checkProjectLimit(ctx, user.ID)
if err != nil { if err != nil {
s.analytics.TrackProjectLimitError(user.ID, user.Email) s.analytics.TrackProjectLimitError(user.ID, user.Email)
@ -3268,7 +3292,7 @@ func (s *Service) authorize(ctx context.Context, userID uuid.UUID, expiration ti
return nil, Error.New("authorization failed. no user with id: %s", userID.String()) return nil, Error.New("authorization failed. no user with id: %s", userID.String())
} }
if user.Status != Active { if user.Status != Active && user.Status != PendingBotVerification {
return nil, Error.New("authorization failed. no active user with id: %s", userID.String()) return nil, Error.New("authorization failed. no active user with id: %s", userID.String())
} }
return WithUser(ctx, user), nil return WithUser(ctx, user), nil
@ -3812,6 +3836,10 @@ func (s *Service) RespondToProjectInvitation(ctx context.Context, projectID uuid
return ErrValidation.New(projInviteResponseInvalidErrMsg) return ErrValidation.New(projInviteResponseInvalidErrMsg)
} }
if user.Status == PendingBotVerification {
return ErrBotUser.New(contactSupportErrMsg)
}
proj, err := s.GetProjectNoAuth(ctx, projectID) proj, err := s.GetProjectNoAuth(ctx, projectID)
if err != nil { if err != nil {
return Error.Wrap(err) return Error.Wrap(err)

View File

@ -158,6 +158,31 @@ func TestService(t *testing.T) {
require.Nil(t, createdProject) require.Nil(t, createdProject)
}) })
t.Run("CreateProject when bot account", func(t *testing.T) {
user, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Bot User",
Email: "mfauser@mail.test",
}, 1)
require.NoError(t, err)
botStatus := console.PendingBotVerification
err = sat.API.DB.Console().Users().Update(ctx, user.ID, console.UpdateUserRequest{
Status: &botStatus,
})
require.NoError(t, err)
userCtx, err := sat.UserContext(ctx, user.ID)
require.NoError(t, err)
// Creating a project by bot account must fail.
createdProject, err := service.CreateProject(userCtx, console.UpsertProjectInfo{
Name: "test name",
})
require.Error(t, err)
require.True(t, console.ErrBotUser.Has(err))
require.Nil(t, createdProject)
})
t.Run("CreateProject with placement", func(t *testing.T) { t.Run("CreateProject with placement", func(t *testing.T) {
uid := planet.Uplinks[2].Projects[0].Owner.ID uid := planet.Uplinks[2].Projects[0].Owner.ID
err := sat.API.DB.Console().Users().Update(ctx, uid, console.UpdateUserRequest{ err := sat.API.DB.Console().Users().Update(ctx, uid, console.UpdateUserRequest{
@ -2514,5 +2539,24 @@ func TestProjectInvitations(t *testing.T) {
err = service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept) err = service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept)
require.True(t, console.ErrProjectInviteInvalid.Has(err)) require.True(t, console.ErrProjectInviteInvalid.Has(err))
}) })
t.Run("respond by bot account", func(t *testing.T) {
user := addUser(t, ctx)
botStatus := console.PendingBotVerification
err := sat.API.DB.Console().Users().Update(ctx, user.ID, console.UpdateUserRequest{
Status: &botStatus,
})
require.NoError(t, err)
proj := addProject(t, ctx)
_ = addInvite(t, ctx, proj, user.Email)
userCtx, err := sat.UserContext(ctx, user.ID)
require.NoError(t, err)
err = service.RespondToProjectInvitation(userCtx, proj.ID, console.ProjectInvitationDecline)
require.Error(t, err)
require.True(t, console.ErrBotUser.Has(err))
})
}) })
} }

View File

@ -87,7 +87,7 @@ type UsersPage struct {
} }
// IsValid checks UserInfo validity and returns error describing whats wrong. // IsValid checks UserInfo validity and returns error describing whats wrong.
// The returned error has the class ErrValiation. // The returned error has the class ErrValidation.
func (user *UserInfo) IsValid() error { func (user *UserInfo) IsValid() error {
// validate fullName // validate fullName
if err := ValidateFullName(user.FullName); err != nil { if err := ValidateFullName(user.FullName); err != nil {
@ -168,16 +168,18 @@ type TokenInfo struct {
type UserStatus int type UserStatus int
const ( const (
// Inactive is a user status that he receives after registration. // Inactive is a status that user receives after registration.
Inactive UserStatus = 0 Inactive UserStatus = 0
// Active is a user status that he receives after account activation. // Active is a status that user receives after account activation.
Active UserStatus = 1 Active UserStatus = 1
// Deleted is a user status that he receives after deleting account. // Deleted is a status that user receives after deleting account.
Deleted UserStatus = 2 Deleted UserStatus = 2
// PendingDeletion is a user status that he receives before deleting account. // PendingDeletion is a status that user receives before deleting account.
PendingDeletion UserStatus = 3 PendingDeletion UserStatus = 3
// LegalHold is a user status that he receives for legal reasons. // LegalHold is a status that user receives for legal reasons.
LegalHold UserStatus = 4 LegalHold UserStatus = 4
// PendingBotVerification is a status that user receives after account activation but with high captcha score.
PendingBotVerification UserStatus = 5
) )
// String returns a string representation of the user status. // String returns a string representation of the user status.
@ -193,6 +195,8 @@ func (s UserStatus) String() string {
return "Pending Deletion" return "Pending Deletion"
case LegalHold: case LegalHold:
return "Legal Hold" return "Legal Hold"
case PendingBotVerification:
return "Pending Bot Verification"
default: default:
return "" return ""
} }

View File

@ -223,6 +223,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
# The maximum body size allowed to be received by the API # The maximum body size allowed to be received by the API
# console.body-size-limit: 100.00 KB # console.body-size-limit: 100.00 KB
# indicates if flagging bot accounts is enabled
# console.captcha.flag-bots-enabled: false
# whether or not captcha is enabled # whether or not captcha is enabled
# console.captcha.login.hcaptcha.enabled: false # console.captcha.login.hcaptcha.enabled: false
@ -259,6 +262,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
# captcha site key # captcha site key
# console.captcha.registration.recaptcha.site-key: "" # console.captcha.registration.recaptcha.site-key: ""
# bad captcha score threshold which is used to prevent bot user activity
# console.captcha.score-cutoff-threshold: 0.8
# url link to contacts page # url link to contacts page
# console.contact-info-url: https://forum.storj.io # console.contact-info-url: https://forum.storj.io