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:
parent
137641f090
commit
9d13c649a2
@ -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:
|
||||
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
}
|
||||
|
6
scripts/testdata/satellite-config.yaml.lock
vendored
6
scripts/testdata/satellite-config.yaml.lock
vendored
@ -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
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
210
web/satellite/src/views/ActivateAccount.vue
Normal file
210
web/satellite/src/views/ActivateAccount.vue
Normal 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>
|
@ -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 didn’t 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user