satellite/console: implement account activation with code

This change implements account activation using OTP code. Based on
whether this activation method is activated, signup will generate a
6-digit code and send it via email. A new endpoint is added to validate
this code and activate the user's account and log them in.

Issue: #6428

Change-Id: Ia78bb123258021bce78ab9e98dce2c900328057a
This commit is contained in:
Wilfred Asomani 2023-11-22 10:34:06 +00:00 committed by Storj Robot
parent 1546732afc
commit 116d8cbea1
6 changed files with 419 additions and 7 deletions

View File

@ -5,9 +5,11 @@ package consoleapi
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"io"
"math/big"
"net/http"
"strings"
"time"
@ -16,6 +18,7 @@ import (
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/common/http/requestid"
"storj.io/common/uuid"
"storj.io/storj/private/post"
"storj.io/storj/private/web"
@ -46,6 +49,7 @@ type Auth struct {
PasswordRecoveryURL string
CancelPasswordRecoveryURL string
ActivateAccountURL string
ActivationCodeEnabled bool
SatelliteName string
service *console.Service
accountFreezeService *console.AccountFreezeService
@ -55,7 +59,7 @@ type Auth struct {
}
// NewAuth is a constructor for api auth controller.
func NewAuth(log *zap.Logger, service *console.Service, accountFreezeService *console.AccountFreezeService, mailService *mailservice.Service, cookieAuth *consolewebauth.CookieAuth, analytics *analytics.Service, satelliteName, externalAddress, letUsKnowURL, termsAndConditionsURL, contactInfoURL, generalRequestURL string) *Auth {
func NewAuth(log *zap.Logger, service *console.Service, accountFreezeService *console.AccountFreezeService, mailService *mailservice.Service, cookieAuth *consolewebauth.CookieAuth, analytics *analytics.Service, satelliteName, externalAddress, letUsKnowURL, termsAndConditionsURL, contactInfoURL, generalRequestURL string, activationCodeEnabled bool) *Auth {
return &Auth{
log: log,
ExternalAddress: externalAddress,
@ -67,6 +71,7 @@ func NewAuth(log *zap.Logger, service *console.Service, accountFreezeService *co
PasswordRecoveryURL: externalAddress + "password-recovery",
CancelPasswordRecoveryURL: externalAddress + "cancel-password-recovery",
ActivateAccountURL: externalAddress + "activation",
ActivationCodeEnabled: activationCodeEnabled,
service: service,
accountFreezeService: accountFreezeService,
mailService: mailService,
@ -295,6 +300,20 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
return
}
var code string
var requestID string
if a.ActivationCodeEnabled {
randNum, err := rand.Int(rand.Reader, big.NewInt(900000))
if err != nil {
a.serveJSONError(ctx, w, console.Error.Wrap(err))
return
}
randNum = randNum.Add(randNum, big.NewInt(100000))
code = randNum.String()
requestID = requestid.FromContext(ctx)
}
user, err = a.service.CreateUser(ctx,
console.CreateUser{
FullName: registerData.FullName,
@ -310,6 +329,8 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
CaptchaResponse: registerData.CaptchaResponse,
IP: ip,
SignupPromoCode: registerData.SignupPromoCode,
ActivationCode: code,
SignupId: requestID,
},
secret,
)
@ -374,6 +395,17 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
a.analytics.TrackCreateUser(trackCreateUserFields)
}
if a.ActivationCodeEnabled {
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: user.Email}},
&console.AccountActivationCodeEmail{
ActivationCode: user.ActivationCode,
},
)
return
}
token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email)
if err != nil {
a.serveJSONError(ctx, w, err)
@ -392,6 +424,93 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
)
}
// ActivateAccount verifies a signup activation code.
func (a *Auth) ActivateAccount(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
var activateData struct {
Email string `json:"email"`
Code string `json:"code"`
SignupId string `json:"signupId"`
}
err = json.NewDecoder(r.Body).Decode(&activateData)
if err != nil {
a.serveJSONError(ctx, w, err)
return
}
verified, unverified, err := a.service.GetUserByEmailWithUnverified(ctx, activateData.Email)
if err != nil && !console.ErrEmailNotFound.Has(err) {
a.serveJSONError(ctx, w, err)
return
}
if verified != nil {
satelliteAddress := a.ExternalAddress
if !strings.HasSuffix(satelliteAddress, "/") {
satelliteAddress += "/"
}
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: verified.Email}},
&console.AccountAlreadyExistsEmail{
Origin: satelliteAddress,
SatelliteName: a.SatelliteName,
SignInLink: satelliteAddress + "login",
ResetPasswordLink: satelliteAddress + "forgot-password",
CreateAccountLink: satelliteAddress + "signup",
},
)
// return error since verified user already exists.
a.serveJSONError(ctx, w, console.ErrUnauthorized.New("user already verified"))
return
}
var user *console.User
if len(unverified) == 0 {
a.serveJSONError(ctx, w, console.ErrEmailNotFound.New("no unverified user found"))
return
}
user = &unverified[0]
if user.ActivationCode != activateData.Code || user.SignupId != activateData.SignupId {
a.serveJSONError(ctx, w, console.ErrActivationCode.New("invalid activation code"))
return
}
err = a.service.SetAccountActive(ctx, user)
if err != nil {
a.serveJSONError(ctx, w, err)
return
}
ip, err := web.GetRequestIP(r)
if err != nil {
a.serveJSONError(ctx, w, err)
return
}
tokenInfo, err := a.service.GenerateSessionToken(ctx, user.ID, user.Email, ip, r.UserAgent())
if err != nil {
a.serveJSONError(ctx, w, err)
return
}
a.cookieAuth.SetTokenCookie(w, *tokenInfo)
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(struct {
console.TokenInfo
Token string `json:"token"`
}{*tokenInfo, tokenInfo.Token.String()})
if err != nil {
a.log.Error("could not encode token response", zap.Error(ErrAuthAPI.Wrap(err)))
return
}
}
// loadSession looks for a cookie for the session id.
// this cookie is set from the reverse proxy if the user opts into cookies from Storj.
func loadSession(req *http.Request) string {
@ -717,6 +836,24 @@ func (a *Auth) ResendEmail(w http.ResponseWriter, r *http.Request) {
user := unverified[0]
if a.ActivationCodeEnabled {
user, err = a.service.SetActivationCodeAndSignupID(ctx, user)
if err != nil {
a.serveJSONError(ctx, w, err)
return
}
a.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: user.Email}},
&console.AccountActivationCodeEmail{
ActivationCode: user.ActivationCode,
},
)
return
}
token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email)
if err != nil {
a.serveJSONError(ctx, w, err)
@ -1108,7 +1245,7 @@ func (a *Auth) getStatusCode(err error) int {
switch {
case console.ErrValidation.Has(err), console.ErrCaptcha.Has(err), console.ErrMFAMissing.Has(err), console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err), console.ErrChangePassword.Has(err), console.ErrInvalidProjectLimit.Has(err):
return http.StatusBadRequest
case console.ErrUnauthorized.Has(err), console.ErrTokenExpiration.Has(err), console.ErrRecoveryToken.Has(err), console.ErrLoginCredentials.Has(err):
case console.ErrUnauthorized.Has(err), console.ErrTokenExpiration.Has(err), console.ErrRecoveryToken.Has(err), console.ErrLoginCredentials.Has(err), console.ErrActivationCode.Has(err):
return http.StatusUnauthorized
case console.ErrEmailUsed.Has(err), console.ErrMFAConflict.Has(err):
return http.StatusConflict
@ -1155,6 +1292,8 @@ func (a *Auth) getUserErrorMessage(err error) string {
return "The server is incapable of fulfilling the request"
case errors.As(err, &maxBytesError):
return "Request body is too large"
case console.ErrActivationCode.Has(err):
return "The activation code is invalid"
default:
return "There was an error processing your request"
}

View File

@ -15,6 +15,7 @@ import (
"net/http/httptest"
"net/url"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
@ -315,7 +316,7 @@ func TestDeleteAccount(t *testing.T) {
actualHandler := func(r *http.Request) (status int, body []byte) {
rr := httptest.NewRecorder()
authController := consoleapi.NewAuth(log, nil, nil, nil, nil, nil, "", "", "", "", "", "")
authController := consoleapi.NewAuth(log, nil, nil, nil, nil, nil, "", "", "", "", "", "", false)
authController.DeleteAccount(rr, r)
result := rr.Result()
@ -731,6 +732,45 @@ func TestRegistrationEmail(t *testing.T) {
})
}
func TestRegistrationEmail_CodeEnabled(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Console.SignupActivationCodeEnabled = true
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
email := "test@mail.test"
sender := &EmailVerifier{Context: ctx}
sat.API.Mail.Service.Sender = sender
jsonBody, err := json.Marshal(map[string]interface{}{
"fullName": "Test User",
"shortName": "Test",
"email": email,
"password": "123a123",
})
require.NoError(t, err)
signupURL := planet.Satellites[0].ConsoleURL() + "/api/v0/auth/register"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signupURL, 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)
require.NoError(t, result.Body.Close())
body, err := sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "code")
})
}
func TestIncreaseLimit(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
@ -839,6 +879,67 @@ func TestResendActivationEmail(t *testing.T) {
})
}
func TestResendActivationEmail_CodeEnabled(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Console.SignupActivationCodeEnabled = true
},
},
}, 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)
// Expect activation e-mail to be sent when using unverified e-mail address.
user.Status = console.Inactive
require.NoError(t, usersRepo.Update(ctx, user.ID, console.UpdateUserRequest{
Status: &user.Status,
}))
sender := &EmailVerifier{Context: ctx}
sat.API.Mail.Service.Sender = sender
resendURL := planet.Satellites[0].ConsoleURL() + "/api/v0/auth/resend-email/" + user.Email
req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendURL, 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)
body, err := sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "code")
regex := regexp.MustCompile(`(\d{6})\n\s*<\/h1>`)
code := strings.Replace(regex.FindString(body.(string)), "</h1>", "", 1)
code = strings.TrimSpace(code)
require.Contains(t, body, code)
// resending should send a new code.
result, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, result.Body.Close())
require.Equal(t, http.StatusOK, result.StatusCode)
body, err = sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "code")
newCode := strings.Replace(regex.FindString(body.(string)), "</h1>", "", 1)
newCode = strings.TrimSpace(newCode)
require.NotEqual(t, code, newCode)
})
}
func TestAuth_Register_ShortPartnerOrPromo(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
@ -961,3 +1062,106 @@ func TestAuth_Register_PasswordLength(t *testing.T) {
}
})
}
func TestAccountActivationWithCode(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Console.SignupActivationCodeEnabled = true
config.Console.RateLimit.Burst = 10
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
email := "test@mail.test"
sender := &EmailVerifier{Context: ctx}
sat.API.Mail.Service.Sender = sender
jsonBody, err := json.Marshal(map[string]interface{}{
"fullName": "Test User",
"shortName": "Test",
"email": email,
"password": "123a123",
})
require.NoError(t, err)
signupURL := planet.Satellites[0].ConsoleURL() + "/api/v0/auth/register"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signupURL, 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)
require.NoError(t, result.Body.Close())
body, err := sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "code")
regex := regexp.MustCompile(`(\d{6})\n\s*<\/h1>`)
code := strings.Replace(regex.FindString(body.(string)), "</h1>", "", 1)
code = strings.TrimSpace(code)
require.Contains(t, body, code)
signupID := result.Header.Get("x-request-id")
activateURL := planet.Satellites[0].ConsoleURL() + "/api/v0/auth/code-activation"
jsonBody, err = json.Marshal(map[string]interface{}{
"email": email,
"code": code,
"signupId": "wrong id",
})
require.NoError(t, err)
req, err = http.NewRequestWithContext(ctx, http.MethodPatch, activateURL, 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.NotEmpty(t, result)
require.Equal(t, http.StatusUnauthorized, result.StatusCode)
require.NoError(t, result.Body.Close())
jsonBody, err = json.Marshal(map[string]interface{}{
"email": email,
"code": code,
"signupId": signupID,
})
require.NoError(t, err)
req, err = http.NewRequestWithContext(ctx, http.MethodPatch, activateURL, 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.NotEmpty(t, result)
require.Equal(t, http.StatusOK, result.StatusCode)
require.NoError(t, result.Body.Close())
cookies := result.Cookies()
require.NoError(t, err)
require.Len(t, cookies, 1)
require.Equal(t, "_tokenKey", cookies[0].Name)
require.NotEmpty(t, cookies[0].Value)
// trying to activate an activated account should send account already exists email
req, err = http.NewRequestWithContext(ctx, http.MethodPatch, activateURL, 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.NotEmpty(t, result)
require.Equal(t, http.StatusUnauthorized, result.StatusCode)
require.NoError(t, result.Body.Close())
body, err = sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "/login")
require.Contains(t, body, "/forgot-password")
require.Contains(t, body, "/signup")
})
}

View File

@ -300,7 +300,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
projectsRouter.Handle("/{id}/daily-usage", http.HandlerFunc(usageLimitsController.DailyUsage)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/usage-report", http.HandlerFunc(usageLimitsController.UsageReport)).Methods(http.MethodGet, http.MethodOptions)
authController := consoleapi.NewAuth(logger, service, accountFreezeService, mailService, server.cookieAuth, server.analytics, config.SatelliteName, server.config.ExternalAddress, config.LetUsKnowURL, config.TermsAndConditionsURL, config.ContactInfoURL, config.GeneralRequestURL)
authController := consoleapi.NewAuth(logger, service, accountFreezeService, mailService, server.cookieAuth, server.analytics, config.SatelliteName, server.config.ExternalAddress, config.LetUsKnowURL, config.TermsAndConditionsURL, config.ContactInfoURL, config.GeneralRequestURL, config.SignupActivationCodeEnabled)
authRouter := router.PathPrefix("/api/v0/auth").Subrouter()
authRouter.Use(server.withCORS)
authRouter.Handle("/account", server.withAuth(http.HandlerFunc(authController.GetAccount))).Methods(http.MethodGet, http.MethodOptions)
@ -321,6 +321,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
authRouter.Handle("/token", server.ipRateLimiter.Limit(http.HandlerFunc(authController.Token))).Methods(http.MethodPost, http.MethodOptions)
authRouter.Handle("/token-by-api-key", server.ipRateLimiter.Limit(http.HandlerFunc(authController.TokenByAPIKey))).Methods(http.MethodPost, http.MethodOptions)
authRouter.Handle("/register", server.ipRateLimiter.Limit(http.HandlerFunc(authController.Register))).Methods(http.MethodPost, http.MethodOptions)
authRouter.Handle("/code-activation", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ActivateAccount))).Methods(http.MethodPatch, http.MethodOptions)
authRouter.Handle("/forgot-password", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ForgotPassword))).Methods(http.MethodPost, http.MethodOptions)
authRouter.Handle("/resend-email/{email}", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResendEmail))).Methods(http.MethodPost, http.MethodOptions)
authRouter.Handle("/reset-password", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResetPassword))).Methods(http.MethodPost, http.MethodOptions)

View File

@ -87,7 +87,7 @@ func (chore *Chore) Run(ctx context.Context) (err error) {
chore.log.Error("error generating activation token", zap.Error(err))
return nil
}
authController := consoleapi.NewAuth(chore.log, nil, nil, nil, nil, nil, "", chore.address, "", "", "", "")
authController := consoleapi.NewAuth(chore.log, nil, nil, nil, nil, nil, "", chore.address, "", "", "", "", false)
link := authController.ActivateAccountURL + "?token=" + token

View File

@ -6,10 +6,12 @@ package console
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"fmt"
"math"
"math/big"
"net/http"
"net/mail"
"sort"
@ -109,6 +111,9 @@ var (
// ErrLoginCredentials occurs when provided invalid login credentials.
ErrLoginCredentials = errs.Class("login credentials")
// ErrActivationCode is error class for failed signup code activation.
ErrActivationCode = errs.Class("activation code")
// ErrChangePassword occurs when provided old password is incorrect.
ErrChangePassword = errs.Class("change password")
@ -837,6 +842,8 @@ func (s *Service) CreateUser(ctx context.Context, user CreateUser, tokenSecret R
HaveSalesContact: user.HaveSalesContact,
SignupPromoCode: user.SignupPromoCode,
SignupCaptcha: captchaScore,
ActivationCode: user.ActivationCode,
SignupId: user.SignupId,
}
if user.UserAgent != nil {
@ -1026,17 +1033,57 @@ func (s *Service) ActivateAccount(ctx context.Context, activationToken string) (
return nil, Error.Wrap(err)
}
err = s.SetAccountActive(ctx, user)
if err != nil {
return nil, err
}
return user, nil
}
// SetAccountActive - is a method for setting user account status to Active and sending
// event to hubspot.
func (s *Service) SetAccountActive(ctx context.Context, user *User) (err error) {
defer mon.Task()(&ctx)(&err)
status := Active
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
Status: &status,
})
if err != nil {
return nil, Error.Wrap(err)
return Error.Wrap(err)
}
s.auditLog(ctx, "activate account", &user.ID, user.Email)
s.auditLog(ctx, "activate account", &user.ID, user.Email)
s.analytics.TrackAccountVerified(user.ID, user.Email)
return nil
}
// SetActivationCodeAndSignupID - generates and updates a new code for user's signup verification.
// It updates the request ID associated with the signup as well.
func (s *Service) SetActivationCodeAndSignupID(ctx context.Context, user User) (_ User, err error) {
defer mon.Task()(&ctx)(&err)
randNum, err := rand.Int(rand.Reader, big.NewInt(900000))
if err != nil {
return User{}, Error.Wrap(err)
}
randNum = randNum.Add(randNum, big.NewInt(100000))
code := randNum.String()
requestID := requestid.FromContext(ctx)
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
ActivationCode: &code,
SignupId: &requestID,
})
if err != nil {
return User{}, Error.Wrap(err)
}
user.SignupId = requestID
user.ActivationCode = code
return user, nil
}

View File

@ -1366,6 +1366,27 @@ func TestUserSettings(t *testing.T) {
})
}
func TestSetActivationCodeAndSignupID(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
srv := sat.API.Console.Service
existingUser, _, err := srv.GetUserByEmailWithUnverified(ctx, planet.Uplinks[0].User[sat.ID()].Email)
require.NoError(t, err)
require.Empty(t, existingUser.ActivationCode)
updatedUser, err := srv.SetActivationCodeAndSignupID(ctx, *existingUser)
require.NoError(t, err)
require.NotEmpty(t, updatedUser.ActivationCode)
updatedUser2, err := srv.SetActivationCodeAndSignupID(ctx, *existingUser)
require.NoError(t, err)
require.NotEqual(t, updatedUser.ActivationCode, updatedUser2.ActivationCode)
})
}
func TestRESTKeys(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,