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.
type CaptchaConfig struct {
Login MultiCaptchaConfig `json:"login"`
Registration MultiCaptchaConfig `json:"registration"`
FlagBotsEnabled bool `help:"indicates if flagging bot accounts is enabled" default:"false"`
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.

View File

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

View File

@ -84,6 +84,7 @@ const (
projInviteDoesntExistErrMsg = "An invitation for '%s' does not exist"
newInviteLimitErrMsg = "Only one new invitation can be sent at a time"
paidTierInviteErrMsg = "Only paid tier users can invite project members"
contactSupportErrMsg = "Please contact support"
)
var (
@ -162,6 +163,9 @@ var (
// ErrNotPaidTier occurs when a user must be paid tier in order to complete an operation.
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.
@ -1047,6 +1051,10 @@ func (s *Service) SetAccountActive(ctx context.Context, user *User) (err error)
defer mon.Task()(&ctx)(&err)
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{
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 len(unverified) > 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)
isBotAccount := false
for _, usr := range nonActiveUsers {
if usr.Status == PendingBotVerification {
isBotAccount = true
botAccount := usr
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()
@ -1763,6 +1783,10 @@ func (s *Service) CreateProject(ctx context.Context, projectInfo UpsertProjectIn
return nil, Error.Wrap(err)
}
if user.Status == PendingBotVerification {
return nil, ErrBotUser.New(contactSupportErrMsg)
}
currentProjectCount, err := s.checkProjectLimit(ctx, user.ID)
if err != nil {
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())
}
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 WithUser(ctx, user), nil
@ -3812,6 +3836,10 @@ func (s *Service) RespondToProjectInvitation(ctx context.Context, projectID uuid
return ErrValidation.New(projInviteResponseInvalidErrMsg)
}
if user.Status == PendingBotVerification {
return ErrBotUser.New(contactSupportErrMsg)
}
proj, err := s.GetProjectNoAuth(ctx, projectID)
if err != nil {
return Error.Wrap(err)

View File

@ -158,6 +158,31 @@ func TestService(t *testing.T) {
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) {
uid := planet.Uplinks[2].Projects[0].Owner.ID
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)
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.
// The returned error has the class ErrValiation.
// The returned error has the class ErrValidation.
func (user *UserInfo) IsValid() error {
// validate fullName
if err := ValidateFullName(user.FullName); err != nil {
@ -168,16 +168,18 @@ type TokenInfo struct {
type UserStatus int
const (
// Inactive is a user status that he receives after registration.
// Inactive is a status that user receives after registration.
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
// Deleted is a user status that he receives after deleting account.
// Deleted is a status that user receives after deleting account.
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
// LegalHold is a user status that he receives for legal reasons.
// LegalHold is a status that user receives for legal reasons.
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.
@ -193,6 +195,8 @@ func (s UserStatus) String() string {
return "Pending Deletion"
case LegalHold:
return "Legal Hold"
case PendingBotVerification:
return "Pending Bot Verification"
default:
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
# 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
# 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
# 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
# console.contact-info-url: https://forum.storj.io