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. // Auth is an api controller that exposes all auth functionality.
type Auth struct { type Auth struct {
log *zap.Logger log *zap.Logger
ExternalAddress string ExternalAddress string
LetUsKnowURL string LetUsKnowURL string
TermsAndConditionsURL string TermsAndConditionsURL string
ContactInfoURL string ContactInfoURL string
service *console.Service PasswordRecoveryURL string
analytics *analytics.Service CancelPasswordRecoveryURL string
mailService *mailservice.Service ActivateAccountURL string
cookieAuth *consolewebauth.CookieAuth service *console.Service
partners *rewards.PartnersService analytics *analytics.Service
mailService *mailservice.Service
cookieAuth *consolewebauth.CookieAuth
partners *rewards.PartnersService
} }
// NewAuth is a constructor for api auth controller. // 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 { 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{ return &Auth{
log: log, log: log,
ExternalAddress: externalAddress, ExternalAddress: externalAddress,
LetUsKnowURL: letUsKnowURL, LetUsKnowURL: letUsKnowURL,
TermsAndConditionsURL: termsAndConditionsURL, TermsAndConditionsURL: termsAndConditionsURL,
ContactInfoURL: contactInfoURL, ContactInfoURL: contactInfoURL,
service: service, PasswordRecoveryURL: externalAddress + "password-recovery/",
mailService: mailService, CancelPasswordRecoveryURL: externalAddress + "cancel-password-recovery/",
cookieAuth: cookieAuth, ActivateAccountURL: externalAddress + "activation/",
partners: partners, service: service,
analytics: analytics, 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) token, err := a.service.Token(ctx, tokenRequest)
if err != nil { 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.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 return
} }
@ -112,6 +120,7 @@ func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
} }
// Register creates new user, sends activation e-mail. // 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) { func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
var err error var err error
@ -130,6 +139,17 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
return 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 { var registerData struct {
FullName string `json:"fullName"` FullName string `json:"fullName"`
ShortName string `json:"shortName"` ShortName string `json:"shortName"`
@ -154,60 +174,107 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
return return
} }
secret, err := console.RegistrationSecretFromBase64(registerData.SecretInput) verified, unverified, err := a.service.GetUserByEmailWithUnverified(ctx, registerData.Email)
if err != nil { if err != nil && !console.ErrEmailNotFound.Has(err) {
a.serveJSONError(w, err) a.serveJSONError(w, err)
return return
} }
if registerData.Partner != "" { if verified != nil {
registerData.UserAgent = []byte(registerData.Partner) recoveryToken, err := a.service.GeneratePasswordRecoveryToken(ctx, verified.ID)
} if err != nil {
a.serveJSONError(w, err)
return
}
ip, err := web.GetRequestIP(r) userName := verified.ShortName
if err != nil { if verified.ShortName == "" {
a.serveJSONError(w, err) 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 return
} }
user, err := a.service.CreateUser(ctx, var user *console.User
console.CreateUser{ if len(unverified) > 0 {
FullName: registerData.FullName, user = &unverified[0]
ShortName: registerData.ShortName, } else {
Email: registerData.Email, secret, err := console.RegistrationSecretFromBase64(registerData.SecretInput)
PartnerID: registerData.PartnerID, if err != nil {
UserAgent: registerData.UserAgent, a.serveJSONError(w, err)
Password: registerData.Password, return
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{ if registerData.Partner != "" {
ID: user.ID, registerData.UserAgent = []byte(registerData.Partner)
AnonymousID: loadSession(r), info, err := a.partners.ByName(ctx, registerData.Partner)
FullName: user.FullName, if err != nil {
Email: user.Email, a.log.Warn("Invalid partner name", zap.String("Partner name", registerData.Partner), zap.String("User email", registerData.Email), zap.Error(err))
Type: analytics.Personal, } 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 { userID = user.ID
trackCreateUserFields.Type = analytics.Professional
trackCreateUserFields.EmployeeCount = user.EmployeeCount
trackCreateUserFields.CompanyName = user.CompanyName
trackCreateUserFields.JobTitle = user.Position
trackCreateUserFields.HaveSalesContact = user.HaveSalesContact
}
a.analytics.TrackCreateUser(trackCreateUserFields)
token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email) token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email)
if err != nil { if err != nil {
@ -215,7 +282,7 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
return return
} }
link := a.ExternalAddress + "activation/?token=" + token link := a.ActivateAccountURL + "?token=" + token
userName := user.ShortName userName := user.ShortName
if user.ShortName == "" { if user.ShortName == "" {
userName = user.FullName userName = user.FullName
@ -230,13 +297,6 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
UserName: userName, 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. // loadSession looks for a cookie for the session id.
@ -395,9 +455,8 @@ func (a *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) {
return return
} }
user, err := a.service.GetUserByEmail(ctx, email) user, _, err := a.service.GetUserByEmailWithUnverified(ctx, email)
if err != nil { if err != nil || user == nil {
a.serveJSONError(w, err)
return return
} }
@ -407,8 +466,8 @@ func (a *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) {
return return
} }
passwordRecoveryLink := a.ExternalAddress + "password-recovery/?token=" + recoveryToken passwordRecoveryLink := a.PasswordRecoveryURL + "?token=" + recoveryToken
cancelPasswordRecoveryLink := a.ExternalAddress + "cancel-password-recovery/?token=" + recoveryToken cancelPasswordRecoveryLink := a.CancelPasswordRecoveryURL + "?token=" + recoveryToken
userName := user.ShortName userName := user.ShortName
if user.ShortName == "" { if user.ShortName == "" {
userName = user.FullName 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) { func (a *Auth) ResendEmail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
var err error var err error
defer mon.Task()(&ctx)(&err) defer mon.Task()(&ctx)(&err)
params := mux.Vars(r) params := mux.Vars(r)
id, ok := params["id"] email, ok := params["email"]
if !ok { if !ok {
a.serveJSONError(w, err)
return return
} }
userID, err := uuid.FromString(id) verified, unverified, err := a.service.GetUserByEmailWithUnverified(ctx, email)
if err != nil { if err != nil {
a.serveJSONError(w, err)
return return
} }
user, err := a.service.GetUser(ctx, userID) if verified != nil {
if err != nil { recoveryToken, err := a.service.GeneratePasswordRecoveryToken(ctx, verified.ID)
a.serveJSONError(w, err) 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 return
} }
user := unverified[0]
token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email) token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email)
if err != nil { if err != nil {
a.serveJSONError(w, err) a.serveJSONError(w, err)
return return
} }
link := a.ExternalAddress + "activation/?token=" + token
userName := user.ShortName userName := user.ShortName
if user.ShortName == "" { if user.ShortName == "" {
userName = user.FullName userName = user.FullName
} }
link := a.ActivateAccountURL + "?token=" + token
contactInfoURL := a.ContactInfoURL contactInfoURL := a.ContactInfoURL
termsAndConditionsURL := a.TermsAndConditionsURL 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. // getStatusCode returns http.StatusCode depends on console error class.
func (a *Auth) getStatusCode(err error) int { func (a *Auth) getStatusCode(err error) int {
switch { 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 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 return http.StatusUnauthorized
case console.ErrEmailUsed.Has(err), console.ErrMFAConflict.Has(err): case console.ErrEmailUsed.Has(err), console.ErrMFAConflict.Has(err):
return http.StatusConflict return http.StatusConflict
case errors.Is(err, errNotImplemented): case errors.Is(err, errNotImplemented):
return http.StatusNotImplemented return http.StatusNotImplemented
case console.ErrMFAMissing.Has(err), console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err): case console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err):
if console.ErrMFALogin.Has(err) {
return http.StatusOK
}
return http.StatusBadRequest return http.StatusBadRequest
default: default:
return http.StatusInternalServerError return http.StatusInternalServerError
@ -642,6 +721,8 @@ func (a *Auth) getUserErrorMessage(err error) string {
return "The MFA passcode is not valid or has expired" return "The MFA passcode is not valid or has expired"
case console.ErrMFARecoveryCode.Has(err): case console.ErrMFARecoveryCode.Has(err):
return "The MFA recovery code is not valid or has been previously used" 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): case errors.Is(err, errNotImplemented):
return "The server is incapable of fulfilling the request" return "The server is incapable of fulfilling the request"
default: default:

View File

@ -5,6 +5,7 @@ package consoleapi_test
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -26,6 +27,7 @@ import (
"storj.io/common/testcontext" "storj.io/common/testcontext"
"storj.io/common/uuid" "storj.io/common/uuid"
"storj.io/storj/private/post"
"storj.io/storj/private/testplanet" "storj.io/storj/private/testplanet"
"storj.io/storj/satellite" "storj.io/storj/satellite"
"storj.io/storj/satellite/console" "storj.io/storj/satellite/console"
@ -453,7 +455,7 @@ func TestMFAEndpoints(t *testing.T) {
Password: user.FullName, Password: user.FullName,
MFARecoveryCode: "BADCODE", MFARecoveryCode: "BADCODE",
}) })
require.True(t, console.ErrUnauthorized.Has(err)) require.True(t, console.ErrMFARecoveryCode.Has(err))
require.Empty(t, newToken) require.Empty(t, newToken)
for _, code := range codes { for _, code := range codes {
@ -470,7 +472,7 @@ func TestMFAEndpoints(t *testing.T) {
// Expect error when providing expired recovery code. // Expect error when providing expired recovery code.
newToken, err = sat.API.Console.Service.Token(ctx, opts) 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) require.Empty(t, newToken)
} }
@ -564,3 +566,128 @@ func TestResetPasswordEndpoint(t *testing.T) {
require.Equal(t, http.StatusOK, tryReset(tokenStr, newPass)) 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 package consoleapi
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"sync"
"github.com/zeebo/errs" "github.com/zeebo/errs"
"go.uber.org/zap" "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))) 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, partnersService,
paymentsService.Accounts(), paymentsService.Accounts(),
analyticsService, analyticsService,
console.Config{PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5}, console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,
TokenExpirationTime: 24 * time.Hour,
},
) )
require.NoError(t, err) require.NoError(t, err)

View File

@ -87,7 +87,11 @@ func TestGraphqlQuery(t *testing.T) {
partnersService, partnersService,
paymentsService.Accounts(), paymentsService.Accounts(),
analyticsService, analyticsService,
console.Config{PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5}, console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,
TokenExpirationTime: 24 * time.Hour,
},
) )
require.NoError(t, err) 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("/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("/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("/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) authRouter.Handle("/reset-password", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResetPassword))).Methods(http.MethodPost)
paymentController := consoleapi.NewPayments(logger, service) paymentController := consoleapi.NewPayments(logger, service)

View File

@ -30,15 +30,12 @@ const (
var ( var (
// ErrMFAMissing is error type that occurs when a request is incomplete // ErrMFAMissing is error type that occurs when a request is incomplete
// due to missing MFA passcode and recovery code. // due to missing MFA passcode or recovery code.
ErrMFAMissing = errs.Class("MFA code required") ErrMFAMissing = errs.Class("MFA credentials missing")
// ErrMFAConflict is error type that occurs when both a passcode and recovery code are given. // ErrMFAConflict is error type that occurs when both a passcode and recovery code are given.
ErrMFAConflict = errs.Class("MFA conflict") 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 is error type that represents usage of invalid MFA recovery code.
ErrMFARecoveryCode = errs.Class("MFA recovery code") ErrMFARecoveryCode = errs.Class("MFA recovery code")

View File

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

View File

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

View File

@ -55,7 +55,7 @@ type Message interface {
// architecture: Service // architecture: Service
type Service struct { type Service struct {
log *zap.Logger log *zap.Logger
sender Sender Sender Sender
html *htmltemplate.Template html *htmltemplate.Template
// TODO(yar): prepare plain text version // TODO(yar): prepare plain text version
@ -67,7 +67,7 @@ type Service struct {
// New creates new service. // New creates new service.
func New(log *zap.Logger, sender Sender, templatePath string) (*Service, error) { func New(log *zap.Logger, sender Sender, templatePath string) (*Service, error) {
var err error var err error
service := &Service{log: log, sender: sender} service := &Service{log: log, Sender: sender}
// TODO(yar): prepare plain text version // TODO(yar): prepare plain text version
// service.text, err = texttemplate.ParseGlob(filepath.Join(templatePath, "*.txt")) // 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. // Send is generalized method for sending custom email message.
func (service *Service) Send(ctx context.Context, msg *post.Message) (err error) { func (service *Service) Send(ctx context.Context, msg *post.Message) (err error) {
defer mon.Task()(&ctx)(&err) 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. // 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{ m := &post.Message{
From: service.sender.FromAddress(), From: service.Sender.FromAddress(),
To: to, To: to,
Subject: msg.Subject(), Subject: msg.Subject(),
PlainText: textBuffer.String(), 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 # reCAPTCHA site key
# admin.console-config.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 # the default free-tier bandwidth usage limit
# admin.console-config.usage-limits.bandwidth.free: 150.00 GB # 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 # url link to terms and conditions page
# console.terms-and-conditions-url: https://storj.io/storage-sla/ # 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 # the default free-tier bandwidth usage limit
# console.usage-limits.bandwidth.free: 150.00 GB # console.usage-limits.bandwidth.free: 150.00 GB

View File

@ -2,7 +2,6 @@
// See LICENSE for copying information. // See LICENSE for copying information.
import { ErrorBadRequest } from '@/api/errors/ErrorBadRequest'; import { ErrorBadRequest } from '@/api/errors/ErrorBadRequest';
import { ErrorEmailUsed } from '@/api/errors/ErrorEmailUsed';
import { ErrorMFARequired } from '@/api/errors/ErrorMFARequired'; import { ErrorMFARequired } from '@/api/errors/ErrorMFARequired';
import { ErrorTooManyRequests } from '@/api/errors/ErrorTooManyRequests'; import { ErrorTooManyRequests } from '@/api/errors/ErrorTooManyRequests';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
@ -16,21 +15,26 @@ import { HttpClient } from '@/utils/httpClient';
export class AuthHttpApi implements UsersApi { export class AuthHttpApi implements UsersApi {
private readonly http: HttpClient = new HttpClient(); private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/auth'; 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. * Used to resend an registration confirmation email.
* *
* @param userId - id of newly created user * @param email - email of newly created user
* @throws Error * @throws Error
*/ */
public async resendEmail(userId: string): Promise<void> { public async resendEmail(email: string): Promise<void> {
const path = `${this.ROOT_PATH}/resend-email/${userId}`; const path = `${this.ROOT_PATH}/resend-email/${email}`;
const response = await this.http.post(path, userId); const response = await this.http.post(path, email);
if (response.ok) { if (response.ok) {
return; 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; return result;
} }
const result = await response.json();
const errMsg = result.error || 'Failed to receive authentication token';
switch (response.status) { switch (response.status) {
case 401: case 401:
throw new ErrorUnauthorized('Your email or password was incorrect, please try again'); throw new ErrorUnauthorized(errMsg);
case 429: case 429:
throw new ErrorTooManyRequests('You\'ve exceeded limit of attempts, try again in 5 minutes'); throw new ErrorTooManyRequests(this.rateLimitErrMsg);
default: default:
throw new Error('Can not receive authentication token'); throw new Error(errMsg);
} }
} }
@ -104,7 +110,16 @@ export class AuthHttpApi implements UsersApi {
return; 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 * @returns id of created user
* @throws Error * @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 path = `${this.ROOT_PATH}/register`;
const body = { const body = {
secret: secret, secret: secret,
@ -244,24 +259,20 @@ export class AuthHttpApi implements UsersApi {
recaptchaResponse: recaptchaResponse, recaptchaResponse: recaptchaResponse,
}; };
const response = await this.http.post(path, JSON.stringify(body)); const response = await this.http.post(path, JSON.stringify(body));
const result = await response.json();
if (!response.ok) { if (!response.ok) {
const result = await response.json();
const errMsg = result.error || 'Cannot register user'; const errMsg = result.error || 'Cannot register user';
switch (response.status) { switch (response.status) {
case 400: case 400:
throw new ErrorBadRequest(errMsg); throw new ErrorBadRequest(errMsg);
case 401: case 401:
throw new ErrorUnauthorized(errMsg); throw new ErrorUnauthorized(errMsg);
case 409:
throw new ErrorEmailUsed(errMsg);
case 429: case 429:
throw new ErrorTooManyRequests('You\'ve exceeded limit of attempts, try again in 5 minutes'); throw new ErrorTooManyRequests(this.rateLimitErrMsg);
default: default:
throw new Error(errMsg); 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"> <div class="register-success-area__form-container">
<MailIcon /> <MailIcon />
<h2 class="register-success-area__form-container__title" aria-roledescription="title">You're almost there!</h2> <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"> <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>
<p class="register-success-area__form-container__text"> <p class="register-success-area__form-container__text">
Didn't receive a verification email? Didn't receive a verification email?
@ -25,7 +30,7 @@
width="450px" width="450px"
height="50px" height="50px"
:on-press="onResendEmailButtonClick" :on-press="onResendEmailButtonClick"
:is-disabled="isResendEmailButtonDisabled" :is-disabled="secondsToWait != 0"
/> />
</div> </div>
<p class="register-success-area__form-container__contact"> <p class="register-success-area__form-container__contact">
@ -46,7 +51,7 @@
</template> </template>
<script lang="ts"> <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'; 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 MailIcon from '@/../static/images/register/mail.svg';
import { AuthHttpApi } from '@/api/auth'; import { AuthHttpApi } from '@/api/auth';
import { LocalData } from '@/utils/localData';
import { RouteConfig } from "@/router"; import { RouteConfig } from "@/router";
// @vue/component // @vue/component
@ -66,8 +70,12 @@ import { RouteConfig } from "@/router";
}, },
}) })
export default class RegistrationSuccess extends Vue { export default class RegistrationSuccess extends Vue {
private isResendEmailButtonDisabled = true; @Prop({default: ''})
private timeToEnableResendEmailButton = '00:30'; private readonly email: string;
@Prop({default: true})
private readonly showManualActivationMsg: boolean;
private secondsToWait = 30;
private intervalID: ReturnType<typeof setInterval>; private intervalID: ReturnType<typeof setInterval>;
private readonly auth: AuthHttpApi = new AuthHttpApi(); 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. * Reloads page.
*/ */
@ -106,25 +121,26 @@ export default class RegistrationSuccess extends Vue {
return window.self !== window.top; 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. * Resend email if interval timer is expired.
*/ */
public async onResendEmailButtonClick(): Promise<void> { public async onResendEmailButtonClick(): Promise<void> {
if (this.isResendEmailButtonDisabled) { const email = this.userEmail;
return; if (this.secondsToWait != 0 || !email) {
}
this.isResendEmailButtonDisabled = true;
const userId = LocalData.getUserId();
if (!userId) {
return; return;
} }
try { try {
await this.auth.resendEmail(userId); await this.auth.resendEmail(email);
} catch (error) { } catch (error) {
await this.$notify.error('Could not send email.'); await this.$notify.error(error.message);
} }
this.startResendEmailCountdown(); this.startResendEmailCountdown();
@ -134,17 +150,11 @@ export default class RegistrationSuccess extends Vue {
* Resets timer blocking email resend button spamming. * Resets timer blocking email resend button spamming.
*/ */
private startResendEmailCountdown(): void { private startResendEmailCountdown(): void {
let countdown = 30; this.secondsToWait = 30;
this.intervalID = setInterval(() => { this.intervalID = setInterval(() => {
countdown--; if (--this.secondsToWait <= 0) {
const secondsLeft = countdown > 9 ? countdown : `0${countdown}`;
this.timeToEnableResendEmailButton = `00:${secondsLeft}`;
if (countdown <= 0) {
clearInterval(this.intervalID); clearInterval(this.intervalID);
this.isResendEmailButtonDisabled = false;
} }
}, 1000); }, 1000);
} }
@ -205,6 +215,11 @@ export default class RegistrationSuccess extends Vue {
margin: 0; margin: 0;
max-width: 350px; max-width: 350px;
text-align: center; text-align: center;
margin-bottom: 27px;
&__email {
font-family: 'font_bold', sans-serif;
}
} }
&__text { &__text {
@ -212,7 +227,6 @@ export default class RegistrationSuccess extends Vue {
font-size: 16px; font-size: 16px;
line-height: 21px; line-height: 21px;
color: #252525; color: #252525;
margin: 27px 0 0 0;
} }
&__verification-cooldown { &__verification-cooldown {

View File

@ -53,6 +53,7 @@ import { OBJECTS_ACTIONS } from '@/store/modules/objects';
import { NavigationLink } from '@/types/navigation'; import { NavigationLink } from '@/types/navigation';
import { MetaUtils } from "@/utils/meta"; import { MetaUtils } from "@/utils/meta";
const ActivateAccount = () => import('@/views/ActivateAccount.vue');
const DashboardArea = () => import('@/views/DashboardArea.vue'); const DashboardArea = () => import('@/views/DashboardArea.vue');
const ForgotPassword = () => import('@/views/ForgotPassword.vue'); const ForgotPassword = () => import('@/views/ForgotPassword.vue');
const LoginArea = () => import('@/views/LoginArea.vue'); const LoginArea = () => import('@/views/LoginArea.vue');
@ -70,6 +71,7 @@ export abstract class RouteConfig {
public static Login = new NavigationLink('/login', 'Login'); public static Login = new NavigationLink('/login', 'Login');
public static Register = new NavigationLink('/signup', 'Register'); public static Register = new NavigationLink('/signup', 'Register');
public static RegisterSuccess = new NavigationLink('/signup-success', 'RegisterSuccess'); 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 ForgotPassword = new NavigationLink('/forgot-password', 'Forgot Password');
public static ResetPassword = new NavigationLink('/password-recovery', 'Reset Password'); public static ResetPassword = new NavigationLink('/password-recovery', 'Reset Password');
public static Account = new NavigationLink('/account', 'Account'); public static Account = new NavigationLink('/account', 'Account');
@ -142,6 +144,7 @@ export const notProjectRelatedRoutes = [
RouteConfig.Login.name, RouteConfig.Login.name,
RouteConfig.Register.name, RouteConfig.Register.name,
RouteConfig.RegisterSuccess.name, RouteConfig.RegisterSuccess.name,
RouteConfig.Activate.name,
RouteConfig.ForgotPassword.name, RouteConfig.ForgotPassword.name,
RouteConfig.ResetPassword.name, RouteConfig.ResetPassword.name,
RouteConfig.Billing.name, RouteConfig.Billing.name,
@ -169,6 +172,11 @@ export const router = new Router({
name: RouteConfig.RegisterSuccess.name, name: RouteConfig.RegisterSuccess.name,
component: RegistrationSuccess, component: RegistrationSuccess,
}, },
{
path: RouteConfig.Activate.path,
name: RouteConfig.Activate.name,
component: ActivateAccount,
},
{ {
path: RouteConfig.ForgotPassword.path, path: RouteConfig.ForgotPassword.path,
name: RouteConfig.ForgotPassword.name, 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" /> <LogoIcon class="logo" @click="onLogoClick" />
</div> </div>
<div class="login-area__content-area"> <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}">
<div v-if="isActivatedBannerShown" class="login-area__content-area__activation-banner" :class="{'error': isActivatedError}"> <p class="login-area__content-area__activation-banner__message">
<p class="login-area__content-area__activation-banner__message"> <template v-if="!isActivatedError"><b>Success!</b> Account verified.</template>
<template v-if="!isActivatedError"><b>Success!</b> Account verified.</template> <template v-else><b>Oops!</b> This account has already been verified.</template>
<template v-else><b>Oops!</b> This account has already been verified.</template> </p>
</p> </div>
</div> <div class="login-area__content-area__container">
<div class="login-area__content-area__container"> <div class="login-area__content-area__container__title-area">
<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>
<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"> <div class="login-area__expand" @click.stop="toggleDropdown">
<span class="login-area__expand__value">{{ satelliteName }}</span> <span class="login-area__expand__value">{{ satelliteName }}</span>
<BottomArrowIcon /> <BottomArrowIcon />
<div v-if="isDropdownShown" v-click-outside="closeDropdown" class="login-area__expand__dropdown"> <div v-if="isDropdownShown" v-click-outside="closeDropdown" class="login-area__expand__dropdown">
<div class="login-area__expand__dropdown__item" @click.stop="closeDropdown"> <div class="login-area__expand__dropdown__item" @click.stop="closeDropdown">
<SelectedCheckIcon /> <SelectedCheckIcon />
<span class="login-area__expand__dropdown__item__name">{{ satelliteName }}</span> <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>
<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> </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 <HeaderlessInput
label="Email Address" label="Email Address"
placeholder="example@email.com" placeholder="example@email.com"
@ -41,7 +50,7 @@
@setData="setEmail" @setData="setEmail"
/> />
</div> </div>
<div v-if="!isMFARequired" class="login-area__input-wrapper"> <div class="login-area__input-wrapper">
<HeaderlessInput <HeaderlessInput
label="Password" label="Password"
placeholder="Password" placeholder="Password"
@ -51,38 +60,40 @@
@setData="setPassword" @setData="setPassword"
/> />
</div> </div>
<div v-if="isMFARequired" class="login-area__content-area__container__mfa"> </template>
<div class="login-area__content-area__container__mfa__info"> <template v-else>
<div class="login-area__content-area__container__mfa__info__title-area"> <div class="info-box">
<WarningIcon /> <div class="info-box__header">
<h2 class="login-area__content-area__container__mfa__info__title-area__txt"> <GreyWarningIcon />
Two-Factor Authentication Required <h2 class="info-box__header__label">
</h2> Two-Factor Authentication Required
</div> </h2>
<p class="login-area__content-area__container__mfa__info__msg">
You'll need the six-digit code from your authenticator app to continue.
</p>
</div> </div>
<ConfirmMFAInput ref="mfaInput" :on-input="onConfirmInput" :is-error="isMFAError" :is-recovery="isRecoveryCodeState" /> <p class="info-box__message">
<span v-if="!isRecoveryCodeState" class="login-area__content-area__container__mfa__recovery" @click="setRecoveryCodeState"> You'll need the six-digit code from your authenticator app to continue.
Or use recovery code </p>
</span>
</div> </div>
<p class="login-area__content-area__container__button" :class="{ 'disabled-button': isLoading }" @click.prevent="onLogin">Sign In</p> <div class="login-area__input-wrapper">
<span v-if="isMFARequired" class="login-area__content-area__container__cancel" :class="{ disabled: isLoading }" @click.prevent="onMFACancelClick"> <ConfirmMFAInput ref="mfaInput" :on-input="onConfirmInput" :is-error="isMFAError" :is-recovery="isRecoveryCodeState" />
Cancel </div>
<span v-if="!isRecoveryCodeState" class="login-area__content-area__container__recovery" @click="setRecoveryCodeState">
Or use recovery code
</span> </span>
</div> </template>
<p class="login-area__content-area__reset-msg"> <p class="login-area__content-area__container__button" :class="{ 'disabled-button': isLoading }" @click.prevent="onLogin">Sign In</p>
Forgot your sign in details? <span v-if="isMFARequired" class="login-area__content-area__container__cancel" :class="{ disabled: isLoading }" @click.prevent="onMFACancelClick">
<router-link :to="forgotPasswordPath" class="login-area__content-area__reset-msg__link"> Cancel
Reset Password </span>
</router-link>
</p>
<router-link :to="registerPath" class="login-area__content-area__register-link">
Need to create an account?
</router-link>
</div> </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>
</div> </div>
</template> </template>
@ -93,7 +104,8 @@ import { Component, Vue } from 'vue-property-decorator';
import ConfirmMFAInput from '@/components/account/mfa/ConfirmMFAInput.vue'; import ConfirmMFAInput from '@/components/account/mfa/ConfirmMFAInput.vue';
import HeaderlessInput from '@/components/common/HeaderlessInput.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 BottomArrowIcon from '@/../static/images/common/lightBottomArrow.svg';
import SelectedCheckIcon from '@/../static/images/common/selectedCheck.svg'; import SelectedCheckIcon from '@/../static/images/common/selectedCheck.svg';
import LogoIcon from '@/../static/images/logo.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 { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
import { AppState } from '@/utils/constants/appStateEnum'; import { AppState } from '@/utils/constants/appStateEnum';
import { Validator } from '@/utils/validation'; import { Validator } from '@/utils/validation';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
interface ClearInput { interface ClearInput {
clearInput(): void; clearInput(): void;
@ -118,6 +131,7 @@ interface ClearInput {
SelectedCheckIcon, SelectedCheckIcon,
LogoIcon, LogoIcon,
WarningIcon, WarningIcon,
GreyWarningIcon,
ConfirmMFAInput, ConfirmMFAInput,
}, },
}) })
@ -138,11 +152,13 @@ export default class Login extends Vue {
public isMFARequired = false; public isMFARequired = false;
public isMFAError = false; public isMFAError = false;
public isRecoveryCodeState = false; public isRecoveryCodeState = false;
public isBadLoginMessageShown = false;
// Tardigrade logic // Tardigrade logic
public isDropdownShown = false; public isDropdownShown = false;
public readonly registerPath: string = RouteConfig.Register.path; public readonly registerPath: string = RouteConfig.Register.path;
public readonly activatePath: string = RouteConfig.Activate.path;
public $refs!: { public $refs!: {
mfaInput: ConfirmMFAInput & ClearInput; mfaInput: ConfirmMFAInput & ClearInput;
@ -276,12 +292,18 @@ export default class Login extends Vue {
if (this.isMFARequired) { if (this.isMFARequired) {
this.isMFAError = true; this.isMFAError = true;
} else { this.isLoading = false;
await this.$notify.error(error.message); 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; return;
} }
@ -328,7 +350,7 @@ export default class Login extends Vue {
&__logo-wrapper { &__logo-wrapper {
text-align: center; text-align: center;
margin-top: 70px; margin: 70px 0;
} }
&__divider { &__divider {
@ -340,6 +362,7 @@ export default class Login extends Vue {
&__input-wrapper { &__input-wrapper {
margin-top: 20px; margin-top: 20px;
width: 100%;
} }
&__expand { &__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 { &__content-area {
background-color: #f5f6fa; background-color: #f5f6fa;
padding: 35px 20px 0 20px; padding: 0 20px;
margin-bottom: 50px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
height: calc(100% - 55px);
border-radius: 20px; border-radius: 20px;
box-sizing: border-box;
&__activation-banner { &__activation-banner {
padding: 20px; padding: 20px;
@ -448,8 +454,10 @@ export default class Login extends Vue {
flex-direction: column; flex-direction: column;
padding: 60px 80px; padding: 60px 80px;
background-color: #fff; background-color: #fff;
min-width: 450px; width: 610px;
border-radius: 20px; border-radius: 20px;
box-sizing: border-box;
margin-bottom: 20px;
&__title-area { &__title-area {
display: flex; display: flex;
@ -461,7 +469,7 @@ export default class Login extends Vue {
line-height: 49px; line-height: 49px;
letter-spacing: -0.100741px; letter-spacing: -0.100741px;
color: #252525; color: #252525;
font-family: 'font_regular', sans-serif; font-family: 'font_bold', sans-serif;
font-weight: 800; font-weight: 800;
} }
@ -501,96 +509,20 @@ export default class Login extends Vue {
cursor: pointer; cursor: pointer;
} }
&__mfa { &__recovery {
margin-top: 25px; font-size: 16px;
display: flex; line-height: 19px;
flex-direction: column; color: #0068dc;
align-items: center; cursor: pointer;
margin-top: 20px;
&__info { text-align: center;
background: #f7f8fb; width: 100%;
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;
}
} }
} }
&__reset-msg { &__footer-item {
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;
margin-top: 30px; margin-top: 30px;
padding-bottom: 30px; font-size: 14px;
}
&__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;
}
} }
} }
} }
@ -610,21 +542,52 @@ export default class Login extends Vue {
border-color: #dadde5; 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) { @media screen and (max-width: 750px) {
.login-area { .login-area {
&__header {
padding: 10px 20px;
width: calc(100% - 40px);
}
&__content-area { &__content-area {
width: 90%;
margin: 0 auto;
&__container { &__container {
min-width: 80%; width: 100%;
padding: 60px;
} }
} }
@ -642,30 +605,15 @@ export default class Login extends Vue {
.login-area { .login-area {
&__logo-wrapper { &__logo-wrapper {
margin-top: 40px; margin: 40px;
} }
&__content-area { &__content-area {
padding: 30px 20px 0 20px; padding: 0;
&__container {
padding: 20px 25px;
min-width: 90%;
}
}
}
}
@media screen and (max-width: 375px) {
.login-area {
&__content-area {
padding: 0 20px 100px 20px;
&__container { &__container {
padding: 0 20px 20px 20px;
background: transparent; background: transparent;
min-width: 100%;
} }
} }
} }

View File

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