satellite/{console,satellitedb}: Forbid creating users with used email

This change disallows creation of users possessing the same email.
If a user attempts to create an account with an email address
that's already used - whether it belongs to an active account or not -
he will be notified of unsuccessful account creation. If he attempts to
log in using an email address belonging to an inactive account,
he will be presented with a link allowing him to re-send the
verification email. Attempting to register with an email address
belonging to an existing account triggers a password reset email.

Change-Id: Iefd8c3bef00ecb1dd9e8504594607aa0dca7d82e
This commit is contained in:
Jeremy Wharton 2021-11-18 13:55:37 -05:00 committed by Maximillian von Briesen
parent 137641f090
commit 9d13c649a2
18 changed files with 865 additions and 401 deletions

View File

@ -41,31 +41,37 @@ var (
// Auth is an api controller that exposes all auth functionality.
type Auth struct {
log *zap.Logger
ExternalAddress string
LetUsKnowURL string
TermsAndConditionsURL string
ContactInfoURL string
service *console.Service
analytics *analytics.Service
mailService *mailservice.Service
cookieAuth *consolewebauth.CookieAuth
partners *rewards.PartnersService
log *zap.Logger
ExternalAddress string
LetUsKnowURL string
TermsAndConditionsURL string
ContactInfoURL string
PasswordRecoveryURL string
CancelPasswordRecoveryURL string
ActivateAccountURL string
service *console.Service
analytics *analytics.Service
mailService *mailservice.Service
cookieAuth *consolewebauth.CookieAuth
partners *rewards.PartnersService
}
// NewAuth is a constructor for api auth controller.
func NewAuth(log *zap.Logger, service *console.Service, mailService *mailservice.Service, cookieAuth *consolewebauth.CookieAuth, partners *rewards.PartnersService, analytics *analytics.Service, externalAddress string, letUsKnowURL string, termsAndConditionsURL string, contactInfoURL string) *Auth {
return &Auth{
log: log,
ExternalAddress: externalAddress,
LetUsKnowURL: letUsKnowURL,
TermsAndConditionsURL: termsAndConditionsURL,
ContactInfoURL: contactInfoURL,
service: service,
mailService: mailService,
cookieAuth: cookieAuth,
partners: partners,
analytics: analytics,
log: log,
ExternalAddress: externalAddress,
LetUsKnowURL: letUsKnowURL,
TermsAndConditionsURL: termsAndConditionsURL,
ContactInfoURL: contactInfoURL,
PasswordRecoveryURL: externalAddress + "password-recovery/",
CancelPasswordRecoveryURL: externalAddress + "cancel-password-recovery/",
ActivateAccountURL: externalAddress + "activation/",
service: service,
mailService: mailService,
cookieAuth: cookieAuth,
partners: partners,
analytics: analytics,
}
}
@ -84,10 +90,12 @@ func (a *Auth) Token(w http.ResponseWriter, r *http.Request) {
token, err := a.service.Token(ctx, tokenRequest)
if err != nil {
if !console.ErrMFAMissing.Has(err) {
if console.ErrMFAMissing.Has(err) {
serveCustomJSONError(a.log, w, 200, err, a.getUserErrorMessage(err))
} else {
a.log.Info("Error authenticating token request", zap.String("email", tokenRequest.Email), zap.Error(ErrAuthAPI.Wrap(err)))
a.serveJSONError(w, err)
}
a.serveJSONError(w, err)
return
}
@ -112,6 +120,7 @@ func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
}
// Register creates new user, sends activation e-mail.
// If a user with the given e-mail address already exists, a password reset e-mail is sent instead.
func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
@ -130,6 +139,17 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
return
}
var userID uuid.UUID
defer func() {
if err == nil {
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(userID)
if err != nil {
a.log.Error("registration handler could not encode userID", zap.Error(ErrAuthAPI.Wrap(err)))
}
}
}()
var registerData struct {
FullName string `json:"fullName"`
ShortName string `json:"shortName"`
@ -154,60 +174,107 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
return
}
secret, err := console.RegistrationSecretFromBase64(registerData.SecretInput)
if err != nil {
verified, unverified, err := a.service.GetUserByEmailWithUnverified(ctx, registerData.Email)
if err != nil && !console.ErrEmailNotFound.Has(err) {
a.serveJSONError(w, err)
return
}
if registerData.Partner != "" {
registerData.UserAgent = []byte(registerData.Partner)
}
if verified != nil {
recoveryToken, err := a.service.GeneratePasswordRecoveryToken(ctx, verified.ID)
if err != nil {
a.serveJSONError(w, err)
return
}
ip, err := web.GetRequestIP(r)
if err != nil {
a.serveJSONError(w, err)
userName := verified.ShortName
if verified.ShortName == "" {
userName = verified.FullName
}
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: verified.Email, Name: userName}},
&consoleql.ForgotPasswordEmail{
Origin: a.ExternalAddress,
UserName: userName,
ResetLink: a.PasswordRecoveryURL + "?token=" + recoveryToken,
CancelPasswordRecoveryLink: a.CancelPasswordRecoveryURL + "?token=" + recoveryToken,
LetUsKnowURL: a.LetUsKnowURL,
ContactInfoURL: a.ContactInfoURL,
TermsAndConditionsURL: a.TermsAndConditionsURL,
},
)
userID = verified.ID
return
}
user, err := a.service.CreateUser(ctx,
console.CreateUser{
FullName: registerData.FullName,
ShortName: registerData.ShortName,
Email: registerData.Email,
PartnerID: registerData.PartnerID,
UserAgent: registerData.UserAgent,
Password: registerData.Password,
IsProfessional: registerData.IsProfessional,
Position: registerData.Position,
CompanyName: registerData.CompanyName,
EmployeeCount: registerData.EmployeeCount,
HaveSalesContact: registerData.HaveSalesContact,
RecaptchaResponse: registerData.RecaptchaResponse,
IP: ip,
},
secret,
)
if err != nil {
a.serveJSONError(w, err)
return
}
var user *console.User
if len(unverified) > 0 {
user = &unverified[0]
} else {
secret, err := console.RegistrationSecretFromBase64(registerData.SecretInput)
if err != nil {
a.serveJSONError(w, err)
return
}
trackCreateUserFields := analytics.TrackCreateUserFields{
ID: user.ID,
AnonymousID: loadSession(r),
FullName: user.FullName,
Email: user.Email,
Type: analytics.Personal,
if registerData.Partner != "" {
registerData.UserAgent = []byte(registerData.Partner)
info, err := a.partners.ByName(ctx, registerData.Partner)
if err != nil {
a.log.Warn("Invalid partner name", zap.String("Partner name", registerData.Partner), zap.String("User email", registerData.Email), zap.Error(err))
} else {
registerData.PartnerID = info.ID
}
}
ip, err := web.GetRequestIP(r)
if err != nil {
a.serveJSONError(w, err)
return
}
user, err = a.service.CreateUser(ctx,
console.CreateUser{
FullName: registerData.FullName,
ShortName: registerData.ShortName,
Email: registerData.Email,
PartnerID: registerData.PartnerID,
UserAgent: registerData.UserAgent,
Password: registerData.Password,
IsProfessional: registerData.IsProfessional,
Position: registerData.Position,
CompanyName: registerData.CompanyName,
EmployeeCount: registerData.EmployeeCount,
HaveSalesContact: registerData.HaveSalesContact,
RecaptchaResponse: registerData.RecaptchaResponse,
IP: ip,
},
secret,
)
if err != nil {
a.serveJSONError(w, err)
return
}
trackCreateUserFields := analytics.TrackCreateUserFields{
ID: user.ID,
AnonymousID: loadSession(r),
FullName: user.FullName,
Email: user.Email,
Type: analytics.Personal,
}
if user.IsProfessional {
trackCreateUserFields.Type = analytics.Professional
trackCreateUserFields.EmployeeCount = user.EmployeeCount
trackCreateUserFields.CompanyName = user.CompanyName
trackCreateUserFields.JobTitle = user.Position
trackCreateUserFields.HaveSalesContact = user.HaveSalesContact
}
a.analytics.TrackCreateUser(trackCreateUserFields)
}
if user.IsProfessional {
trackCreateUserFields.Type = analytics.Professional
trackCreateUserFields.EmployeeCount = user.EmployeeCount
trackCreateUserFields.CompanyName = user.CompanyName
trackCreateUserFields.JobTitle = user.Position
trackCreateUserFields.HaveSalesContact = user.HaveSalesContact
}
a.analytics.TrackCreateUser(trackCreateUserFields)
userID = user.ID
token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email)
if err != nil {
@ -215,7 +282,7 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
return
}
link := a.ExternalAddress + "activation/?token=" + token
link := a.ActivateAccountURL + "?token=" + token
userName := user.ShortName
if user.ShortName == "" {
userName = user.FullName
@ -230,13 +297,6 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
UserName: userName,
},
)
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(user.ID)
if err != nil {
a.log.Error("registration handler could not encode userID", zap.Error(ErrAuthAPI.Wrap(err)))
return
}
}
// loadSession looks for a cookie for the session id.
@ -395,9 +455,8 @@ func (a *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) {
return
}
user, err := a.service.GetUserByEmail(ctx, email)
if err != nil {
a.serveJSONError(w, err)
user, _, err := a.service.GetUserByEmailWithUnverified(ctx, email)
if err != nil || user == nil {
return
}
@ -407,8 +466,8 @@ func (a *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) {
return
}
passwordRecoveryLink := a.ExternalAddress + "password-recovery/?token=" + recoveryToken
cancelPasswordRecoveryLink := a.ExternalAddress + "cancel-password-recovery/?token=" + recoveryToken
passwordRecoveryLink := a.PasswordRecoveryURL + "?token=" + recoveryToken
cancelPasswordRecoveryLink := a.CancelPasswordRecoveryURL + "?token=" + recoveryToken
userName := user.ShortName
if user.ShortName == "" {
userName = user.FullName
@ -433,43 +492,66 @@ func (a *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) {
)
}
// ResendEmail generates activation token by userID and sends email account activation email to user.
// ResendEmail generates activation token by e-mail address and sends email account activation email to user.
// If the account is already activated, a password reset e-mail is sent instead.
func (a *Auth) ResendEmail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
params := mux.Vars(r)
id, ok := params["id"]
email, ok := params["email"]
if !ok {
a.serveJSONError(w, err)
return
}
userID, err := uuid.FromString(id)
verified, unverified, err := a.service.GetUserByEmailWithUnverified(ctx, email)
if err != nil {
a.serveJSONError(w, err)
return
}
user, err := a.service.GetUser(ctx, userID)
if err != nil {
a.serveJSONError(w, err)
if verified != nil {
recoveryToken, err := a.service.GeneratePasswordRecoveryToken(ctx, verified.ID)
if err != nil {
a.serveJSONError(w, err)
return
}
userName := verified.ShortName
if verified.ShortName == "" {
userName = verified.FullName
}
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: verified.Email, Name: userName}},
&consoleql.ForgotPasswordEmail{
Origin: a.ExternalAddress,
UserName: userName,
ResetLink: a.PasswordRecoveryURL + "?token=" + recoveryToken,
CancelPasswordRecoveryLink: a.CancelPasswordRecoveryURL + "?token=" + recoveryToken,
LetUsKnowURL: a.LetUsKnowURL,
ContactInfoURL: a.ContactInfoURL,
TermsAndConditionsURL: a.TermsAndConditionsURL,
},
)
return
}
user := unverified[0]
token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email)
if err != nil {
a.serveJSONError(w, err)
return
}
link := a.ExternalAddress + "activation/?token=" + token
userName := user.ShortName
if user.ShortName == "" {
userName = user.FullName
}
link := a.ActivateAccountURL + "?token=" + token
contactInfoURL := a.ContactInfoURL
termsAndConditionsURL := a.TermsAndConditionsURL
@ -602,18 +684,15 @@ func (a *Auth) serveJSONError(w http.ResponseWriter, err error) {
// getStatusCode returns http.StatusCode depends on console error class.
func (a *Auth) getStatusCode(err error) int {
switch {
case console.ErrValidation.Has(err), console.ErrRecaptcha.Has(err):
case console.ErrValidation.Has(err), console.ErrRecaptcha.Has(err), console.ErrMFAMissing.Has(err):
return http.StatusBadRequest
case console.ErrUnauthorized.Has(err), console.ErrRecoveryToken.Has(err):
case console.ErrUnauthorized.Has(err), console.ErrRecoveryToken.Has(err), console.ErrLoginCredentials.Has(err):
return http.StatusUnauthorized
case console.ErrEmailUsed.Has(err), console.ErrMFAConflict.Has(err):
return http.StatusConflict
case errors.Is(err, errNotImplemented):
return http.StatusNotImplemented
case console.ErrMFAMissing.Has(err), console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err):
if console.ErrMFALogin.Has(err) {
return http.StatusOK
}
case console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err):
return http.StatusBadRequest
default:
return http.StatusInternalServerError
@ -642,6 +721,8 @@ func (a *Auth) getUserErrorMessage(err error) string {
return "The MFA passcode is not valid or has expired"
case console.ErrMFARecoveryCode.Has(err):
return "The MFA recovery code is not valid or has been previously used"
case console.ErrLoginCredentials.Has(err):
return "Your login credentials are incorrect, please try again"
case errors.Is(err, errNotImplemented):
return "The server is incapable of fulfilling the request"
default:

View File

@ -5,6 +5,7 @@ package consoleapi_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@ -26,6 +27,7 @@ import (
"storj.io/common/testcontext"
"storj.io/common/uuid"
"storj.io/storj/private/post"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
@ -453,7 +455,7 @@ func TestMFAEndpoints(t *testing.T) {
Password: user.FullName,
MFARecoveryCode: "BADCODE",
})
require.True(t, console.ErrUnauthorized.Has(err))
require.True(t, console.ErrMFARecoveryCode.Has(err))
require.Empty(t, newToken)
for _, code := range codes {
@ -470,7 +472,7 @@ func TestMFAEndpoints(t *testing.T) {
// Expect error when providing expired recovery code.
newToken, err = sat.API.Console.Service.Token(ctx, opts)
require.True(t, console.ErrUnauthorized.Has(err))
require.True(t, console.ErrMFARecoveryCode.Has(err))
require.Empty(t, newToken)
}
@ -564,3 +566,128 @@ func TestResetPasswordEndpoint(t *testing.T) {
require.Equal(t, http.StatusOK, tryReset(tokenStr, newPass))
})
}
type EmailVerifier struct {
Data consoleapi.ContextChannel
Context context.Context
}
func (v *EmailVerifier) SendEmail(ctx context.Context, msg *post.Message) error {
body := ""
for _, part := range msg.Parts {
body += part.Content
}
return v.Data.Send(v.Context, body)
}
func (v *EmailVerifier) FromAddress() post.Address {
return post.Address{}
}
func TestRegistrationEmail(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
jsonBody, err := json.Marshal(map[string]interface{}{
"fullName": "Test User",
"shortName": "Test",
"email": "test@mail.test",
"password": "123a123",
})
require.NoError(t, err)
register := func() string {
url := planet.Satellites[0].ConsoleURL() + "/api/v0/auth/register"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
result, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, result.StatusCode)
var userID string
require.NoError(t, json.NewDecoder(result.Body).Decode(&userID))
require.NoError(t, result.Body.Close())
return userID
}
sender := &EmailVerifier{Context: ctx}
sat.API.Mail.Service.Sender = sender
// Registration attempts using new e-mail address should send activation e-mail.
userID := register()
body, err := sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "/activation")
// Registration attempts using existing but unverified e-mail address should send activation e-mail.
newUserID := register()
require.Equal(t, userID, newUserID)
body, err = sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "/activation")
// Registration attempts using existing and verified e-mail address should send password reset e-mail.
userUUID, err := uuid.FromString(userID)
require.NoError(t, err)
user, err := sat.DB.Console().Users().Get(ctx, userUUID)
require.NoError(t, err)
user.Status = console.Active
require.NoError(t, sat.DB.Console().Users().Update(ctx, user))
newUserID = register()
require.Equal(t, userID, newUserID)
body, err = sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "/password-recovery")
})
}
func TestResendActivationEmail(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
usersRepo := sat.DB.Console().Users()
user, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Test User",
Email: "test@mail.test",
}, 1)
require.NoError(t, err)
resendEmail := func() {
url := planet.Satellites[0].ConsoleURL() + "/api/v0/auth/resend-email/" + user.Email
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(user.Email))
require.NoError(t, err)
result, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, result.Body.Close())
require.Equal(t, http.StatusOK, result.StatusCode)
}
sender := &EmailVerifier{Context: ctx}
sat.API.Mail.Service.Sender = sender
// Expect password reset e-mail to be sent when using verified e-mail address.
resendEmail()
body, err := sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "/password-recovery")
// Expect activation e-mail to be sent when using unverified e-mail address.
user.Status = console.Inactive
require.NoError(t, usersRepo.Update(ctx, user))
resendEmail()
body, err = sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "/activation")
})
}

View File

@ -4,8 +4,10 @@
package consoleapi
import (
"context"
"encoding/json"
"net/http"
"sync"
"github.com/zeebo/errs"
"go.uber.org/zap"
@ -48,3 +50,52 @@ func serveCustomJSONError(log *zap.Logger, w http.ResponseWriter, status int, er
log.Error("failed to write json error response", zap.Error(ErrUtils.Wrap(err)))
}
}
// ContextChannel is a generic, context-aware channel.
type ContextChannel struct {
mu sync.Mutex
channel chan interface{}
initialized bool
}
// Get waits until a value is sent and returns it, or returns an error if the context has closed.
func (c *ContextChannel) Get(ctx context.Context) (interface{}, error) {
c.initialize()
select {
case val := <-c.channel:
return val, nil
default:
select {
case <-ctx.Done():
return nil, ErrUtils.New("context closed")
case val := <-c.channel:
return val, nil
}
}
}
// Send waits until a value can be sent and sends it, or returns an error if the context has closed.
func (c *ContextChannel) Send(ctx context.Context, val interface{}) error {
c.initialize()
select {
case c.channel <- val:
return nil
default:
select {
case <-ctx.Done():
return ErrUtils.New("context closed")
case c.channel <- val:
return nil
}
}
}
func (c *ContextChannel) initialize() {
c.mu.Lock()
defer c.mu.Unlock()
if c.initialized {
return
}
c.channel = make(chan interface{})
c.initialized = true
}

View File

@ -103,7 +103,11 @@ func TestGraphqlMutation(t *testing.T) {
partnersService,
paymentsService.Accounts(),
analyticsService,
console.Config{PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5},
console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,
TokenExpirationTime: 24 * time.Hour,
},
)
require.NoError(t, err)

View File

@ -87,7 +87,11 @@ func TestGraphqlQuery(t *testing.T) {
partnersService,
paymentsService.Accounts(),
analyticsService,
console.Config{PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5},
console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,
TokenExpirationTime: 24 * time.Hour,
},
)
require.NoError(t, err)

View File

@ -240,7 +240,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, mail
authRouter.Handle("/token", server.ipRateLimiter.Limit(http.HandlerFunc(authController.Token))).Methods(http.MethodPost)
authRouter.Handle("/register", server.ipRateLimiter.Limit(http.HandlerFunc(authController.Register))).Methods(http.MethodPost, http.MethodOptions)
authRouter.Handle("/forgot-password/{email}", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ForgotPassword))).Methods(http.MethodPost)
authRouter.Handle("/resend-email/{id}", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResendEmail))).Methods(http.MethodPost)
authRouter.Handle("/resend-email/{email}", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResendEmail))).Methods(http.MethodPost)
authRouter.Handle("/reset-password", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResetPassword))).Methods(http.MethodPost)
paymentController := consoleapi.NewPayments(logger, service)

View File

@ -30,15 +30,12 @@ const (
var (
// ErrMFAMissing is error type that occurs when a request is incomplete
// due to missing MFA passcode and recovery code.
ErrMFAMissing = errs.Class("MFA code required")
// due to missing MFA passcode or recovery code.
ErrMFAMissing = errs.Class("MFA credentials missing")
// ErrMFAConflict is error type that occurs when both a passcode and recovery code are given.
ErrMFAConflict = errs.Class("MFA conflict")
// ErrMFALogin is error type caused by MFA that occurs when logging in / retrieving token.
ErrMFALogin = errs.Class("MFA login")
// ErrMFARecoveryCode is error type that represents usage of invalid MFA recovery code.
ErrMFARecoveryCode = errs.Class("MFA recovery code")

View File

@ -6,8 +6,6 @@ package console
import (
"context"
"crypto/subtle"
"database/sql"
"errors"
"fmt"
"net/mail"
"sort"
@ -38,10 +36,6 @@ const (
// maxLimit specifies the limit for all paged queries.
maxLimit = 50
// TokenExpirationTime specifies the expiration time for
// auth tokens, account recovery tokens, and activation tokens.
TokenExpirationTime = 24 * time.Hour
// TestPasswordCost is the hashing complexity to use for testing.
TestPasswordCost = bcrypt.MinCost
)
@ -50,14 +44,16 @@ const (
const (
unauthorizedErrMsg = "You are not authorized to perform this action"
emailUsedErrMsg = "This email is already in use, try another"
emailNotFoundErrMsg = "There are no users with the specified email"
passwordRecoveryTokenIsExpiredErrMsg = "Your password recovery link has expired, please request another one"
credentialsErrMsg = "Your email or password was incorrect, please try again"
credentialsErrMsg = "Your login credentials are incorrect, please try again"
passwordIncorrectErrMsg = "Your password needs at least %d characters long"
projectOwnerDeletionForbiddenErrMsg = "%s is a project owner and can not be deleted"
apiKeyWithNameExistsErrMsg = "An API Key with this name already exists in this project, please use a different name"
apiKeyWithNameDoesntExistErrMsg = "An API Key with this name doesn't exist in this project."
teamMemberDoesNotExistErrMsg = `There is no account on this Satellite for the user(s) you have entered.
Please add team members with active accounts`
activationTokenExpiredErrMsg = "This activation token has expired, please request another one"
usedRegTokenErrMsg = "This registration token has already been used"
projLimitErrMsg = "Sorry, project creation is limited for your account. Please contact support!"
@ -79,9 +75,15 @@ var (
// ErrUsage is error type of project usage.
ErrUsage = errs.Class("project usage")
// ErrLoginCredentials occurs when provided invalid login credentials.
ErrLoginCredentials = errs.Class("login credentials")
// ErrEmailUsed is error type that occurs on repeating auth attempts with email.
ErrEmailUsed = errs.Class("email used")
// ErrEmailNotFound occurs when no users have the specified email.
ErrEmailNotFound = errs.Class("email not found")
// ErrNoAPIKey is error type that occurs when there is no api key found.
ErrNoAPIKey = errs.Class("no api key found")
@ -128,9 +130,10 @@ func init() {
// Config keeps track of core console service configuration parameters.
type Config struct {
PasswordCost int `help:"password hashing cost (0=automatic)" testDefault:"4" default:"0"`
OpenRegistrationEnabled bool `help:"enable open registration" default:"false" testDefault:"true"`
DefaultProjectLimit int `help:"default project limits for users" default:"1" testDefault:"5"`
PasswordCost int `help:"password hashing cost (0=automatic)" testDefault:"4" default:"0"`
OpenRegistrationEnabled bool `help:"enable open registration" default:"false" testDefault:"true"`
DefaultProjectLimit int `help:"default project limits for users" default:"1" testDefault:"5"`
TokenExpirationTime time.Duration `help:"expiration time for auth tokens, account recovery tokens, and activation tokens" default:"24h"`
UsageLimits UsageLimitsConfig
Recaptcha RecaptchaConfig
}
@ -590,13 +593,13 @@ func (s *Service) CreateUser(ctx context.Context, user CreateUser, tokenSecret R
return nil, ErrRegToken.Wrap(err)
}
u, err = s.store.Users().GetByEmail(ctx, user.Email)
if err == nil {
return nil, ErrEmailUsed.New(emailUsedErrMsg)
}
if !errors.Is(err, sql.ErrNoRows) {
verified, unverified, err := s.store.Users().GetByEmailWithUnverified(ctx, user.Email)
if err != nil {
return nil, Error.Wrap(err)
}
if verified != nil || len(unverified) != 0 {
return nil, ErrEmailUsed.New(emailUsedErrMsg)
}
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), s.config.PasswordCost)
if err != nil {
@ -678,7 +681,7 @@ func (s *Service) GenerateActivationToken(ctx context.Context, id uuid.UUID, ema
claims := &consoleauth.Claims{
ID: id,
Email: email,
Expiration: time.Now().Add(time.Hour * 24),
Expiration: time.Now().Add(s.config.TokenExpirationTime),
}
return s.createToken(ctx, claims)
@ -720,6 +723,10 @@ func (s *Service) ActivateAccount(ctx context.Context, activationToken string) (
return "", err
}
if time.Now().After(claims.Expiration) {
return "", ErrTokenExpiration.New(activationTokenExpiredErrMsg)
}
_, err = s.store.Users().GetByEmail(ctx, claims.Email)
if err == nil {
return "", ErrEmailUsed.New(emailUsedErrMsg)
@ -730,12 +737,6 @@ func (s *Service) ActivateAccount(ctx context.Context, activationToken string) (
return "", Error.Wrap(err)
}
now := time.Now()
if now.After(user.CreatedAt.Add(TokenExpirationTime)) {
return "", ErrTokenExpiration.Wrap(err)
}
user.Status = Active
err = s.store.Users().Update(ctx, user)
if err != nil {
@ -748,7 +749,7 @@ func (s *Service) ActivateAccount(ctx context.Context, activationToken string) (
// now that the account is activated, create a token to be stored in a cookie to log the user in.
claims = &consoleauth.Claims{
ID: user.ID,
Expiration: time.Now().Add(TokenExpirationTime),
Expiration: time.Now().Add(s.config.TokenExpirationTime),
}
token, err = s.createToken(ctx, claims)
@ -784,7 +785,7 @@ func (s *Service) ResetPassword(ctx context.Context, resetPasswordToken, passwor
return Error.Wrap(err)
}
if t.Sub(token.CreatedAt) > TokenExpirationTime {
if t.Sub(token.CreatedAt) > s.config.TokenExpirationTime {
return ErrRecoveryToken.Wrap(ErrTokenExpiration.New(passwordRecoveryTokenIsExpiredErrMsg))
}
@ -824,14 +825,14 @@ func (s *Service) RevokeResetPasswordToken(ctx context.Context, resetPasswordTok
func (s *Service) Token(ctx context.Context, request AuthUser) (token string, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.store.Users().GetByEmail(ctx, request.Email)
if err != nil {
return "", ErrUnauthorized.New(credentialsErrMsg)
user, _, err := s.store.Users().GetByEmailWithUnverified(ctx, request.Email)
if user == nil {
return "", ErrLoginCredentials.New(credentialsErrMsg)
}
err = bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(request.Password))
if err != nil {
return "", ErrUnauthorized.New(credentialsErrMsg)
return "", ErrLoginCredentials.New(credentialsErrMsg)
}
if user.MFAEnabled {
@ -850,7 +851,7 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token string, er
}
}
if !found {
return "", ErrUnauthorized.New(mfaRecoveryInvalidErrMsg)
return "", ErrMFARecoveryCode.New(mfaRecoveryInvalidErrMsg)
}
user.MFARecoveryCodes = append(user.MFARecoveryCodes[:codeIndex], user.MFARecoveryCodes[codeIndex+1:]...)
@ -862,19 +863,19 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token string, er
} else if request.MFAPasscode != "" {
valid, err := ValidateMFAPasscode(request.MFAPasscode, user.MFASecretKey, time.Now())
if err != nil {
return "", ErrUnauthorized.Wrap(err)
return "", ErrMFAPasscode.Wrap(err)
}
if !valid {
return "", ErrUnauthorized.New(mfaPasscodeInvalidErrMsg)
return "", ErrMFAPasscode.New(mfaPasscodeInvalidErrMsg)
}
} else {
return "", ErrMFALogin.Wrap(ErrMFAMissing.New(mfaRequiredErrMsg))
return "", ErrMFAMissing.New(mfaRequiredErrMsg)
}
}
claims := consoleauth.Claims{
ID: user.ID,
Expiration: time.Now().Add(TokenExpirationTime),
Expiration: time.Now().Add(s.config.TokenExpirationTime),
}
token, err = s.createToken(ctx, &claims)
@ -911,16 +912,20 @@ func (s *Service) GetUserID(ctx context.Context) (id uuid.UUID, err error) {
return auth.User.ID, nil
}
// GetUserByEmail returns User by email.
func (s *Service) GetUserByEmail(ctx context.Context, email string) (u *User, err error) {
// GetUserByEmailWithUnverified returns Users by email.
func (s *Service) GetUserByEmailWithUnverified(ctx context.Context, email string) (verified *User, unverified []User, err error) {
defer mon.Task()(&ctx)(&err)
result, err := s.store.Users().GetByEmail(ctx, email)
verified, unverified, err = s.store.Users().GetByEmailWithUnverified(ctx, email)
if err != nil {
return nil, Error.Wrap(err)
return verified, unverified, err
}
return result, nil
if verified == nil && len(unverified) == 0 {
err = ErrEmailNotFound.New(emailNotFoundErrMsg)
}
return verified, unverified, err
}
// UpdateAccount updates User.
@ -964,8 +969,11 @@ func (s *Service) ChangeEmail(ctx context.Context, newEmail string) (err error)
return ErrValidation.Wrap(err)
}
_, err = s.store.Users().GetByEmail(ctx, newEmail)
if err == nil {
verified, unverified, err := s.store.Users().GetByEmailWithUnverified(ctx, newEmail)
if err != nil {
return Error.Wrap(err)
}
if verified != nil || len(unverified) != 0 {
return ErrEmailUsed.New(emailUsedErrMsg)
}

View File

@ -225,9 +225,9 @@ func TestService(t *testing.T) {
err = service.ChangeEmail(authCtx2, newEmail)
require.NoError(t, err)
userWithUpdatedEmail, err := service.GetUserByEmail(authCtx2, newEmail)
user, _, err := service.GetUserByEmailWithUnverified(authCtx2, newEmail)
require.NoError(t, err)
require.Equal(t, newEmail, userWithUpdatedEmail.Email)
require.Equal(t, newEmail, user.Email)
err = service.ChangeEmail(authCtx2, newEmail)
require.Error(t, err)
@ -437,7 +437,7 @@ func TestMFA(t *testing.T) {
request.MFAPasscode = wrongCode
token, err = service.Token(ctx, request)
require.True(t, console.ErrUnauthorized.Has(err))
require.True(t, console.ErrMFAPasscode.Has(err))
require.Empty(t, token)
// Expect token when providing valid passcode.
@ -469,7 +469,7 @@ func TestMFA(t *testing.T) {
// Expect no token due to providing previously-used recovery code.
token, err = service.Token(ctx, request)
require.True(t, console.ErrUnauthorized.Has(err))
require.True(t, console.ErrMFARecoveryCode.Has(err))
require.Empty(t, token)
updateAuth()
@ -582,7 +582,7 @@ func TestResetPassword(t *testing.T) {
require.True(t, console.ErrRecoveryToken.Has(err))
// Expect error when providing good but expired token.
err = service.ResetPassword(ctx, tokenStr, newPass, token.CreatedAt.Add(console.TokenExpirationTime).Add(time.Second))
err = service.ResetPassword(ctx, tokenStr, newPass, token.CreatedAt.Add(sat.Config.Console.TokenExpirationTime).Add(time.Second))
require.True(t, console.ErrTokenExpiration.Has(err))
// Expect error when providing good token with bad (too short) password.

View File

@ -55,7 +55,7 @@ type Message interface {
// architecture: Service
type Service struct {
log *zap.Logger
sender Sender
Sender Sender
html *htmltemplate.Template
// TODO(yar): prepare plain text version
@ -67,7 +67,7 @@ type Service struct {
// New creates new service.
func New(log *zap.Logger, sender Sender, templatePath string) (*Service, error) {
var err error
service := &Service{log: log, sender: sender}
service := &Service{log: log, Sender: sender}
// TODO(yar): prepare plain text version
// service.text, err = texttemplate.ParseGlob(filepath.Join(templatePath, "*.txt"))
@ -92,7 +92,7 @@ func (service *Service) Close() error {
// Send is generalized method for sending custom email message.
func (service *Service) Send(ctx context.Context, msg *post.Message) (err error) {
defer mon.Task()(&ctx)(&err)
return service.sender.SendEmail(ctx, msg)
return service.Sender.SendEmail(ctx, msg)
}
// SendRenderedAsync renders content from htmltemplate and texttemplate templates then sends it asynchronously.
@ -140,7 +140,7 @@ func (service *Service) SendRendered(ctx context.Context, to []post.Address, msg
}
m := &post.Message{
From: service.sender.FromAddress(),
From: service.Sender.FromAddress(),
To: to,
Subject: msg.Subject(),
PlainText: textBuffer.String(),
@ -152,5 +152,5 @@ func (service *Service) SendRendered(ctx context.Context, to []post.Address, msg
},
}
return service.sender.SendEmail(ctx, m)
return service.Sender.SendEmail(ctx, m)
}

View File

@ -19,6 +19,9 @@
# reCAPTCHA site key
# admin.console-config.recaptcha.site-key: ""
# expiration time for auth tokens, account recovery tokens, and activation tokens
# admin.console-config.token-expiration-time: 24h0m0s
# the default free-tier bandwidth usage limit
# admin.console-config.usage-limits.bandwidth.free: 150.00 GB
@ -235,6 +238,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
# url link to terms and conditions page
# console.terms-and-conditions-url: https://storj.io/storage-sla/
# expiration time for auth tokens, account recovery tokens, and activation tokens
# console.token-expiration-time: 24h0m0s
# the default free-tier bandwidth usage limit
# console.usage-limits.bandwidth.free: 150.00 GB

View File

@ -2,7 +2,6 @@
// See LICENSE for copying information.
import { ErrorBadRequest } from '@/api/errors/ErrorBadRequest';
import { ErrorEmailUsed } from '@/api/errors/ErrorEmailUsed';
import { ErrorMFARequired } from '@/api/errors/ErrorMFARequired';
import { ErrorTooManyRequests } from '@/api/errors/ErrorTooManyRequests';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
@ -16,21 +15,26 @@ import { HttpClient } from '@/utils/httpClient';
export class AuthHttpApi implements UsersApi {
private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/auth';
private readonly rateLimitErrMsg = 'You\'ve exceeded limit of attempts, try again in 5 minutes';
/**
* Used to resend an registration confirmation email.
*
* @param userId - id of newly created user
* @param email - email of newly created user
* @throws Error
*/
public async resendEmail(userId: string): Promise<void> {
const path = `${this.ROOT_PATH}/resend-email/${userId}`;
const response = await this.http.post(path, userId);
public async resendEmail(email: string): Promise<void> {
const path = `${this.ROOT_PATH}/resend-email/${email}`;
const response = await this.http.post(path, email);
if (response.ok) {
return;
}
throw new Error('can not resend Email');
if (response.status == 429) {
throw new ErrorTooManyRequests(this.rateLimitErrMsg);
}
throw new Error('Failed to send email');
}
/**
@ -61,13 +65,15 @@ export class AuthHttpApi implements UsersApi {
return result;
}
const result = await response.json();
const errMsg = result.error || 'Failed to receive authentication token';
switch (response.status) {
case 401:
throw new ErrorUnauthorized('Your email or password was incorrect, please try again');
throw new ErrorUnauthorized(errMsg);
case 429:
throw new ErrorTooManyRequests('You\'ve exceeded limit of attempts, try again in 5 minutes');
throw new ErrorTooManyRequests(this.rateLimitErrMsg);
default:
throw new Error('Can not receive authentication token');
throw new Error(errMsg);
}
}
@ -104,7 +110,16 @@ export class AuthHttpApi implements UsersApi {
return;
}
throw new Error('There is no such email');
const result = await response.json();
const errMsg = result.error || 'Failed to send password reset link';
switch (response.status) {
case 404:
throw new ErrorUnauthorized(errMsg);
case 429:
throw new ErrorTooManyRequests(this.rateLimitErrMsg);
default:
throw new Error(errMsg);
}
}
/**
@ -226,7 +241,7 @@ export class AuthHttpApi implements UsersApi {
* @returns id of created user
* @throws Error
*/
public async register(user: {fullName: string; shortName: string; email: string; partner: string; partnerId: string; password: string; isProfessional: boolean; position: string; companyName: string; employeeCount: string; haveSalesContact: boolean }, secret: string, recaptchaResponse: string): Promise<string> {
public async register(user: {fullName: string; shortName: string; email: string; partner: string; partnerId: string; password: string; isProfessional: boolean; position: string; companyName: string; employeeCount: string; haveSalesContact: boolean }, secret: string, recaptchaResponse: string): Promise<void> {
const path = `${this.ROOT_PATH}/register`;
const body = {
secret: secret,
@ -244,24 +259,20 @@ export class AuthHttpApi implements UsersApi {
recaptchaResponse: recaptchaResponse,
};
const response = await this.http.post(path, JSON.stringify(body));
const result = await response.json();
if (!response.ok) {
const result = await response.json();
const errMsg = result.error || 'Cannot register user';
switch (response.status) {
case 400:
throw new ErrorBadRequest(errMsg);
case 401:
throw new ErrorUnauthorized(errMsg);
case 409:
throw new ErrorEmailUsed(errMsg);
case 429:
throw new ErrorTooManyRequests('You\'ve exceeded limit of attempts, try again in 5 minutes');
throw new ErrorTooManyRequests(this.rateLimitErrMsg);
default:
throw new Error(errMsg);
}
}
return result;
}
/**

View File

@ -1,11 +0,0 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
/**
* ErrorEmailUsed is a custom error type for performing 'email is already in use' operations.
*/
export class ErrorEmailUsed extends Error {
public constructor(message = 'email used') {
super(message);
}
}

View File

@ -10,8 +10,13 @@
<div class="register-success-area__form-container">
<MailIcon />
<h2 class="register-success-area__form-container__title" aria-roledescription="title">You're almost there!</h2>
<div v-if="showManualActivationMsg" class="register-success-area__form-container__sub-title">
If an account with the email address
<p class="register-success-area__form-container__sub-title__email">{{ userEmail }}</p>
exists, a verification email has been sent.
</div>
<p class="register-success-area__form-container__sub-title">
Check your email to confirm your account and get started.
Check your inbox to activate your account and get started.
</p>
<p class="register-success-area__form-container__text">
Didn't receive a verification email?
@ -25,7 +30,7 @@
width="450px"
height="50px"
:on-press="onResendEmailButtonClick"
:is-disabled="isResendEmailButtonDisabled"
:is-disabled="secondsToWait != 0"
/>
</div>
<p class="register-success-area__form-container__contact">
@ -46,7 +51,7 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Component, Prop, Vue } from 'vue-property-decorator';
import VButton from '@/components/common/VButton.vue';
@ -54,7 +59,6 @@ import LogoIcon from '@/../static/images/logo.svg';
import MailIcon from '@/../static/images/register/mail.svg';
import { AuthHttpApi } from '@/api/auth';
import { LocalData } from '@/utils/localData';
import { RouteConfig } from "@/router";
// @vue/component
@ -66,8 +70,12 @@ import { RouteConfig } from "@/router";
},
})
export default class RegistrationSuccess extends Vue {
private isResendEmailButtonDisabled = true;
private timeToEnableResendEmailButton = '00:30';
@Prop({default: ''})
private readonly email: string;
@Prop({default: true})
private readonly showManualActivationMsg: boolean;
private secondsToWait = 30;
private intervalID: ReturnType<typeof setInterval>;
private readonly auth: AuthHttpApi = new AuthHttpApi();
@ -92,6 +100,13 @@ export default class RegistrationSuccess extends Vue {
}
}
/**
* Gets email (either passed in as prop or via query param).
*/
public get userEmail(): string {
return this.email || this.$route.query.email.toString();
}
/**
* Reloads page.
*/
@ -106,25 +121,26 @@ export default class RegistrationSuccess extends Vue {
return window.self !== window.top;
}
/**
* Returns the time left until the Resend Email button is enabled in mm:ss form.
*/
public get timeToEnableResendEmailButton(): string {
return `${Math.floor(this.secondsToWait / 60).toString().padStart(2, '0')}:${(this.secondsToWait % 60).toString().padStart(2, '0')}`;
}
/**
* Resend email if interval timer is expired.
*/
public async onResendEmailButtonClick(): Promise<void> {
if (this.isResendEmailButtonDisabled) {
return;
}
this.isResendEmailButtonDisabled = true;
const userId = LocalData.getUserId();
if (!userId) {
const email = this.userEmail;
if (this.secondsToWait != 0 || !email) {
return;
}
try {
await this.auth.resendEmail(userId);
await this.auth.resendEmail(email);
} catch (error) {
await this.$notify.error('Could not send email.');
await this.$notify.error(error.message);
}
this.startResendEmailCountdown();
@ -134,17 +150,11 @@ export default class RegistrationSuccess extends Vue {
* Resets timer blocking email resend button spamming.
*/
private startResendEmailCountdown(): void {
let countdown = 30;
this.secondsToWait = 30;
this.intervalID = setInterval(() => {
countdown--;
const secondsLeft = countdown > 9 ? countdown : `0${countdown}`;
this.timeToEnableResendEmailButton = `00:${secondsLeft}`;
if (countdown <= 0) {
if (--this.secondsToWait <= 0) {
clearInterval(this.intervalID);
this.isResendEmailButtonDisabled = false;
}
}, 1000);
}
@ -205,6 +215,11 @@ export default class RegistrationSuccess extends Vue {
margin: 0;
max-width: 350px;
text-align: center;
margin-bottom: 27px;
&__email {
font-family: 'font_bold', sans-serif;
}
}
&__text {
@ -212,7 +227,6 @@ export default class RegistrationSuccess extends Vue {
font-size: 16px;
line-height: 21px;
color: #252525;
margin: 27px 0 0 0;
}
&__verification-cooldown {

View File

@ -53,6 +53,7 @@ import { OBJECTS_ACTIONS } from '@/store/modules/objects';
import { NavigationLink } from '@/types/navigation';
import { MetaUtils } from "@/utils/meta";
const ActivateAccount = () => import('@/views/ActivateAccount.vue');
const DashboardArea = () => import('@/views/DashboardArea.vue');
const ForgotPassword = () => import('@/views/ForgotPassword.vue');
const LoginArea = () => import('@/views/LoginArea.vue');
@ -70,6 +71,7 @@ export abstract class RouteConfig {
public static Login = new NavigationLink('/login', 'Login');
public static Register = new NavigationLink('/signup', 'Register');
public static RegisterSuccess = new NavigationLink('/signup-success', 'RegisterSuccess');
public static Activate = new NavigationLink('/activate', 'Activate');
public static ForgotPassword = new NavigationLink('/forgot-password', 'Forgot Password');
public static ResetPassword = new NavigationLink('/password-recovery', 'Reset Password');
public static Account = new NavigationLink('/account', 'Account');
@ -142,6 +144,7 @@ export const notProjectRelatedRoutes = [
RouteConfig.Login.name,
RouteConfig.Register.name,
RouteConfig.RegisterSuccess.name,
RouteConfig.Activate.name,
RouteConfig.ForgotPassword.name,
RouteConfig.ResetPassword.name,
RouteConfig.Billing.name,
@ -169,6 +172,11 @@ export const router = new Router({
name: RouteConfig.RegisterSuccess.name,
component: RegistrationSuccess,
},
{
path: RouteConfig.Activate.path,
name: RouteConfig.Activate.name,
component: ActivateAccount,
},
{
path: RouteConfig.ForgotPassword.path,
name: RouteConfig.ForgotPassword.name,

View File

@ -0,0 +1,210 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="activate-area">
<div class="activate-area__logo-wrapper">
<LogoIcon class="activate-area__logo-wrapper_logo" @click="onLogoClick" />
</div>
<div class="activate-area__content-area">
<RegistrationSuccess v-if="isRegistrationSuccessShown" :email="email" />
<div v-else class="activate-area__content-area__container">
<h1 class="activate-area__content-area__container__title">Activate Account</h1>
<div class="activate-area__content-area__container__input-wrapper">
<HeaderlessInput
label="Email Address"
placeholder="example@email.com"
:error="emailError"
height="46px"
width="100%"
@setData="setEmail"
/>
</div>
<p class="activate-area__content-area__container__button" @click.prevent="onActivateClick">Activate</p>
</div>
<router-link :to="loginPath" class="activate-area__content-area__login-link">
Back to Login
</router-link>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import HeaderlessInput from '@/components/common/HeaderlessInput.vue';
import RegistrationSuccess from '@/components/common/RegistrationSuccess.vue';
import LogoIcon from '@/../static/images/logo.svg';
import { AuthHttpApi } from '@/api/auth';
import { RouteConfig } from '@/router';
import { Validator } from '@/utils/validation';
// @vue/component
@Component({
components: {
LogoIcon,
HeaderlessInput,
RegistrationSuccess,
},
})
export default class ActivateAccount extends Vue {
private email = '';
private emailError = '';
private isRegistrationSuccessShown = false;
public readonly loginPath: string = RouteConfig.Login.path;
private readonly auth: AuthHttpApi = new AuthHttpApi();
/**
* onActivateClick validates input fields and requests resending of activation email.
*/
public async onActivateClick(): Promise<void> {
if (!Validator.email(this.email)) {
this.emailError = "Invalid email";
return;
}
try {
await this.auth.resendEmail(this.email);
this.isRegistrationSuccessShown = true;
} catch (error) {
this.$notify.error(error.message);
}
}
/**
* setEmail sets the email property to the given value.
*/
public setEmail(value: string): void {
this.email = value.trim();
this.emailError = '';
}
/**
* onLogoClick reloads the page.
*/
public onLogoClick(): void {
location.reload();
}
}
</script>
<style lang="scss" scoped>
.activate-area {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
font-family: 'font_regular', sans-serif;
background-color: #f5f6fa;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-height: 100%;
overflow-y: scroll;
&__logo-wrapper {
text-align: center;
margin: 70px 0;
&__logo {
cursor: pointer;
}
}
&__content-area {
width: 100%;
padding: 0 20px;
margin-bottom: 50px;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
&__container {
width: 610px;
padding: 60px 80px;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 20px;
box-sizing: border-box;
&__input-wrapper {
margin-top: 20px;
}
&__title {
font-size: 24px;
margin: 10px 0;
color: #252525;
font-family: 'font_bold', sans-serif;
}
&__button {
margin-top: 40px;
display: flex;
justify-content: center;
align-items: center;
background-color: #376fff;
border-radius: 50px;
color: #fff;
cursor: pointer;
width: 100%;
height: 48px;
&:hover {
background-color: #0059d0;
}
}
}
&__login-link {
font-family: 'font_medium', sans-serif;
text-decoration: none;
font-size: 14px;
color: #376fff;
margin-top: 50px;
}
}
}
@media screen and (max-width: 750px) {
.activate-area {
&__content-area {
&__container {
width: 100%;
padding: 60px;
}
}
}
}
@media screen and (max-width: 414px) {
.activate-area {
&__logo-wrapper {
margin: 40px;
}
&__content-area {
padding: 0;
&__container {
padding: 20px;
padding-top: 0;
background: transparent;
}
}
}
}
</style>

View File

@ -7,32 +7,41 @@
<LogoIcon class="logo" @click="onLogoClick" />
</div>
<div class="login-area__content-area">
<div class="login-area__content-area">
<div v-if="isActivatedBannerShown" class="login-area__content-area__activation-banner" :class="{'error': isActivatedError}">
<p class="login-area__content-area__activation-banner__message">
<template v-if="!isActivatedError"><b>Success!</b> Account verified.</template>
<template v-else><b>Oops!</b> This account has already been verified.</template>
</p>
</div>
<div class="login-area__content-area__container">
<div class="login-area__content-area__container__title-area">
<h1 class="login-area__content-area__container__title-area__title" aria-roledescription="sign-in-title">Sign In</h1>
<div v-if="isActivatedBannerShown" class="login-area__content-area__activation-banner" :class="{'error': isActivatedError}">
<p class="login-area__content-area__activation-banner__message">
<template v-if="!isActivatedError"><b>Success!</b> Account verified.</template>
<template v-else><b>Oops!</b> This account has already been verified.</template>
</p>
</div>
<div class="login-area__content-area__container">
<div class="login-area__content-area__container__title-area">
<h1 class="login-area__content-area__container__title-area__title" aria-roledescription="sign-in-title">Sign In</h1>
<div class="login-area__expand" @click.stop="toggleDropdown">
<span class="login-area__expand__value">{{ satelliteName }}</span>
<BottomArrowIcon />
<div v-if="isDropdownShown" v-click-outside="closeDropdown" class="login-area__expand__dropdown">
<div class="login-area__expand__dropdown__item" @click.stop="closeDropdown">
<SelectedCheckIcon />
<span class="login-area__expand__dropdown__item__name">{{ satelliteName }}</span>
</div>
<a v-for="sat in partneredSatellites" :key="sat.id" class="login-area__expand__dropdown__item" :href="sat.address + '/login'">
{{ sat.name }}
</a>
<div class="login-area__expand" @click.stop="toggleDropdown">
<span class="login-area__expand__value">{{ satelliteName }}</span>
<BottomArrowIcon />
<div v-if="isDropdownShown" v-click-outside="closeDropdown" class="login-area__expand__dropdown">
<div class="login-area__expand__dropdown__item" @click.stop="closeDropdown">
<SelectedCheckIcon />
<span class="login-area__expand__dropdown__item__name">{{ satelliteName }}</span>
</div>
<a v-for="sat in partneredSatellites" :key="sat.id" class="login-area__expand__dropdown__item" :href="sat.address + '/login'">
{{ sat.name }}
</a>
</div>
</div>
<div v-if="!isMFARequired" class="login-area__input-wrapper">
</div>
<template v-if="!isMFARequired">
<div v-if="isBadLoginMessageShown" class="info-box error">
<div class="info-box__header">
<WarningIcon />
<h2 class="info-box__header__label">Invalid Credentials</h2>
</div>
<p class="info-box__message">
Your login credentials are incorrect. If you didnt receive an activation email, click <router-link :to="activatePath" class="link">here</router-link>.
</p>
</div>
<div class="login-area__input-wrapper">
<HeaderlessInput
label="Email Address"
placeholder="example@email.com"
@ -41,7 +50,7 @@
@setData="setEmail"
/>
</div>
<div v-if="!isMFARequired" class="login-area__input-wrapper">
<div class="login-area__input-wrapper">
<HeaderlessInput
label="Password"
placeholder="Password"
@ -51,38 +60,40 @@
@setData="setPassword"
/>
</div>
<div v-if="isMFARequired" class="login-area__content-area__container__mfa">
<div class="login-area__content-area__container__mfa__info">
<div class="login-area__content-area__container__mfa__info__title-area">
<WarningIcon />
<h2 class="login-area__content-area__container__mfa__info__title-area__txt">
Two-Factor Authentication Required
</h2>
</div>
<p class="login-area__content-area__container__mfa__info__msg">
You'll need the six-digit code from your authenticator app to continue.
</p>
</template>
<template v-else>
<div class="info-box">
<div class="info-box__header">
<GreyWarningIcon />
<h2 class="info-box__header__label">
Two-Factor Authentication Required
</h2>
</div>
<ConfirmMFAInput ref="mfaInput" :on-input="onConfirmInput" :is-error="isMFAError" :is-recovery="isRecoveryCodeState" />
<span v-if="!isRecoveryCodeState" class="login-area__content-area__container__mfa__recovery" @click="setRecoveryCodeState">
Or use recovery code
</span>
<p class="info-box__message">
You'll need the six-digit code from your authenticator app to continue.
</p>
</div>
<p class="login-area__content-area__container__button" :class="{ 'disabled-button': isLoading }" @click.prevent="onLogin">Sign In</p>
<span v-if="isMFARequired" class="login-area__content-area__container__cancel" :class="{ disabled: isLoading }" @click.prevent="onMFACancelClick">
Cancel
<div class="login-area__input-wrapper">
<ConfirmMFAInput ref="mfaInput" :on-input="onConfirmInput" :is-error="isMFAError" :is-recovery="isRecoveryCodeState" />
</div>
<span v-if="!isRecoveryCodeState" class="login-area__content-area__container__recovery" @click="setRecoveryCodeState">
Or use recovery code
</span>
</div>
<p class="login-area__content-area__reset-msg">
Forgot your sign in details?
<router-link :to="forgotPasswordPath" class="login-area__content-area__reset-msg__link">
Reset Password
</router-link>
</p>
<router-link :to="registerPath" class="login-area__content-area__register-link">
Need to create an account?
</router-link>
</template>
<p class="login-area__content-area__container__button" :class="{ 'disabled-button': isLoading }" @click.prevent="onLogin">Sign In</p>
<span v-if="isMFARequired" class="login-area__content-area__container__cancel" :class="{ disabled: isLoading }" @click.prevent="onMFACancelClick">
Cancel
</span>
</div>
<p class="login-area__content-area__footer-item">
Forgot your sign in details?
<router-link :to="forgotPasswordPath" class="link">
Reset Password
</router-link>
</p>
<router-link :to="registerPath" class="login-area__content-area__footer-item link">
Need to create an account?
</router-link>
</div>
</div>
</template>
@ -93,7 +104,8 @@ import { Component, Vue } from 'vue-property-decorator';
import ConfirmMFAInput from '@/components/account/mfa/ConfirmMFAInput.vue';
import HeaderlessInput from '@/components/common/HeaderlessInput.vue';
import WarningIcon from '@/../static/images/common/greyWarning.svg';
import WarningIcon from '@/../static/images/accessGrants/warning.svg';
import GreyWarningIcon from '@/../static/images/common/greyWarning.svg';
import BottomArrowIcon from '@/../static/images/common/lightBottomArrow.svg';
import SelectedCheckIcon from '@/../static/images/common/selectedCheck.svg';
import LogoIcon from '@/../static/images/logo.svg';
@ -105,6 +117,7 @@ import { PartneredSatellite } from '@/types/common';
import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
import { AppState } from '@/utils/constants/appStateEnum';
import { Validator } from '@/utils/validation';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
interface ClearInput {
clearInput(): void;
@ -118,6 +131,7 @@ interface ClearInput {
SelectedCheckIcon,
LogoIcon,
WarningIcon,
GreyWarningIcon,
ConfirmMFAInput,
},
})
@ -138,11 +152,13 @@ export default class Login extends Vue {
public isMFARequired = false;
public isMFAError = false;
public isRecoveryCodeState = false;
public isBadLoginMessageShown = false;
// Tardigrade logic
public isDropdownShown = false;
public readonly registerPath: string = RouteConfig.Register.path;
public readonly activatePath: string = RouteConfig.Activate.path;
public $refs!: {
mfaInput: ConfirmMFAInput & ClearInput;
@ -276,12 +292,18 @@ export default class Login extends Vue {
if (this.isMFARequired) {
this.isMFAError = true;
} else {
await this.$notify.error(error.message);
this.isLoading = false;
return;
}
this.isLoading = false;
if (error instanceof ErrorUnauthorized) {
this.isBadLoginMessageShown = true;
this.isLoading = false;
return;
}
await this.$notify.error(error.message);
this.isLoading = false;
return;
}
@ -328,7 +350,7 @@ export default class Login extends Vue {
&__logo-wrapper {
text-align: center;
margin-top: 70px;
margin: 70px 0;
}
&__divider {
@ -340,6 +362,7 @@ export default class Login extends Vue {
&__input-wrapper {
margin-top: 20px;
width: 100%;
}
&__expand {
@ -394,32 +417,15 @@ export default class Login extends Vue {
}
}
&__link {
display: flex;
justify-content: center;
align-items: center;
width: 191px;
height: 44px;
border: 2px solid #376fff;
border-radius: 6px;
color: #376fff;
background-color: #fff;
cursor: pointer;
&:hover {
background-color: #376fff;
color: #fff;
}
}
&__content-area {
background-color: #f5f6fa;
padding: 35px 20px 0 20px;
padding: 0 20px;
margin-bottom: 50px;
display: flex;
flex-direction: column;
align-items: center;
height: calc(100% - 55px);
border-radius: 20px;
box-sizing: border-box;
&__activation-banner {
padding: 20px;
@ -448,8 +454,10 @@ export default class Login extends Vue {
flex-direction: column;
padding: 60px 80px;
background-color: #fff;
min-width: 450px;
width: 610px;
border-radius: 20px;
box-sizing: border-box;
margin-bottom: 20px;
&__title-area {
display: flex;
@ -461,7 +469,7 @@ export default class Login extends Vue {
line-height: 49px;
letter-spacing: -0.100741px;
color: #252525;
font-family: 'font_regular', sans-serif;
font-family: 'font_bold', sans-serif;
font-weight: 800;
}
@ -501,96 +509,20 @@ export default class Login extends Vue {
cursor: pointer;
}
&__mfa {
margin-top: 25px;
display: flex;
flex-direction: column;
align-items: center;
&__info {
background: #f7f8fb;
border-radius: 6px;
padding: 20px;
margin-bottom: 20px;
&__title-area {
display: flex;
align-items: center;
&__txt {
font-family: 'font_Bold', sans-serif;
font-size: 16px;
line-height: 19px;
color: #1b2533;
margin-left: 15px;
}
}
&__msg {
font-size: 16px;
line-height: 19px;
color: #1b2533;
margin-top: 10px;
max-width: 410px;
}
}
&__recovery {
font-size: 16px;
line-height: 19px;
color: #0068dc;
cursor: pointer;
margin-top: 20px;
text-align: center;
}
&__recovery {
font-size: 16px;
line-height: 19px;
color: #0068dc;
cursor: pointer;
margin-top: 20px;
text-align: center;
width: 100%;
}
}
&__reset-msg {
font-size: 14px;
line-height: 18px;
margin-top: 50px;
text-align: center;
&__link {
font-family: 'font_medium', sans-serif;
text-decoration: none;
font-size: 14px;
line-height: 18px;
color: #376fff;
}
}
&__register-link {
font-family: 'font_medium', sans-serif;
font-size: 14px;
line-height: 18px;
color: #376fff;
&__footer-item {
margin-top: 30px;
padding-bottom: 30px;
}
&__footer {
display: flex;
justify-content: center;
align-items: flex-start;
margin-top: 140px;
width: 100%;
&__copyright {
font-size: 12px;
line-height: 18px;
color: #384b65;
padding-bottom: 20px;
}
&__link {
font-size: 12px;
line-height: 18px;
margin-left: 30px;
color: #376fff;
text-decoration: none;
}
font-size: 14px;
}
}
}
@ -610,21 +542,52 @@ export default class Login extends Vue {
border-color: #dadde5;
}
.link {
color: #376fff;
font-family: 'font_medium', sans-serif;
}
.info-box {
background-color: #f7f8fb;
border-radius: 6px;
padding: 20px;
margin-top: 25px;
width: 100%;
box-sizing: border-box;
&.error {
background-color: #fff9f7;
border: 1px solid #f84b00;
}
&__header {
display: flex;
align-items: center;
&__label {
font-family: 'font_bold', sans-serif;
font-size: 16px;
color: #1b2533;
margin-left: 15px;
}
}
&__message {
font-size: 16px;
color: #1b2533;
margin-top: 10px;
}
}
@media screen and (max-width: 750px) {
.login-area {
&__header {
padding: 10px 20px;
width: calc(100% - 40px);
}
&__content-area {
width: 90%;
margin: 0 auto;
&__container {
min-width: 80%;
width: 100%;
padding: 60px;
}
}
@ -642,30 +605,15 @@ export default class Login extends Vue {
.login-area {
&__logo-wrapper {
margin-top: 40px;
margin: 40px;
}
&__content-area {
padding: 30px 20px 0 20px;
&__container {
padding: 20px 25px;
min-width: 90%;
}
}
}
}
@media screen and (max-width: 375px) {
.login-area {
&__content-area {
padding: 0 20px 100px 20px;
padding: 0;
&__container {
padding: 0 20px 20px 20px;
background: transparent;
min-width: 100%;
}
}
}

View File

@ -230,7 +230,6 @@ import { AuthHttpApi } from '@/api/auth';
import { RouteConfig } from '@/router';
import { PartneredSatellite } from '@/types/common';
import { User } from '@/types/users';
import { LocalData } from '@/utils/localData';
import { MetaUtils } from '@/utils/meta';
import { Validator } from '@/utils/validation';
@ -429,6 +428,13 @@ export default class RegisterArea extends Vue {
return this.$store.state.appStateModule.couponCodeSigunpUIEnabled;
}
/**
* Returns the email of the created user.
*/
public get email(): string {
return this.user.email;
}
/**
* Sets user's company name field from value string.
*/
@ -561,9 +567,9 @@ export default class RegisterArea extends Vue {
this.user.haveSalesContact = this.haveSalesContact;
try {
this.userId = await this.auth.register(this.user, this.secret, this.recaptchaResponseToken);
LocalData.setUserId(this.userId);
await this.detectBraveBrowser() ? await this.$router.push(RouteConfig.RegisterSuccess.path) : location.replace(RouteConfig.RegisterSuccess.path);
await this.auth.register(this.user, this.secret, this.recaptchaResponseToken);
const successPath = RouteConfig.RegisterSuccess.path + "?email=" + this.user.email;
await this.detectBraveBrowser() ? await this.$router.push(successPath) : location.replace(successPath);
} catch (error) {
if (this.$refs.recaptcha) {
this.$refs.recaptcha.reset();