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.
|
// 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:
|
||||||
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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")
|
||||||
|
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
6
scripts/testdata/satellite-config.yaml.lock
vendored
6
scripts/testdata/satellite-config.yaml.lock
vendored
@ -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
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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">
|
<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 {
|
||||||
|
@ -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,
|
||||||
|
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" />
|
<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 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
|
<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%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
Loading…
Reference in New Issue
Block a user