satellite/{web,console}: verify invite link

This change adds a new endpoint to verify the validity of an invite
link. It conditionally redirects to the login page with or without
whether the invite is valid.

Related: https://github.com/storj/storj/issues/5741

Change-Id: I587ef8ded67a9ea753e4edec1beeecd39c949922
This commit is contained in:
Wilfred Asomani 2023-06-14 10:09:11 +00:00
parent 6268c75d3f
commit dbd575e50b
5 changed files with 292 additions and 3 deletions

View File

@ -357,6 +357,8 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
fs := http.FileServer(http.Dir(server.config.StaticDir))
router.PathPrefix("/static/").Handler(server.brotliMiddleware(http.StripPrefix("/static", fs)))
router.HandleFunc("/invited", server.handleInvited)
// These paths previously required a trailing slash, so we support both forms for now
slashRouter := router.NewRoute().Subrouter()
slashRouter.StrictSlash(true)
@ -715,6 +717,34 @@ func (server *Server) cancelPasswordRecoveryHandler(w http.ResponseWriter, r *ht
http.Redirect(w, r, "https://storjlabs.atlassian.net/servicedesk/customer/portals", http.StatusSeeOther)
}
func (server *Server) handleInvited(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
defer mon.Task()(&ctx)(nil)
token := r.URL.Query().Get("invite")
if token == "" {
server.serveError(w, http.StatusBadRequest)
return
}
loginLink := server.config.ExternalAddress + "login"
invite, err := server.service.GetInviteByToken(ctx, token)
if err != nil {
server.log.Error("handleInvited: error checking invitation", zap.Error(err))
if console.ErrProjectInviteInvalid.Has(err) {
http.Redirect(w, r, loginLink+"?invite_invalid=true", http.StatusTemporaryRedirect)
return
}
server.serveError(w, http.StatusInternalServerError)
return
}
email := strings.ToLower(invite.Email)
http.Redirect(w, r, loginLink+"?email="+email, http.StatusTemporaryRedirect)
}
// graphqlHandler is graphql endpoint http handler function.
func (server *Server) graphqlHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@ -79,6 +79,76 @@ func TestActivationRouting(t *testing.T) {
})
}
func TestInvitedRouting(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]
service := sat.API.Console.Service
user, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Test User",
Email: "u@mail.test",
}, 1)
require.NoError(t, err)
user2, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Test User2",
Email: "u2@mail.test",
}, 1)
require.NoError(t, err)
ctx1, err := sat.UserContext(ctx, user.ID)
require.NoError(t, err)
project, err := sat.AddProject(ctx1, user.ID, "Test Project")
require.NoError(t, err)
client := http.Client{}
checkInvitedRedirect := func(testMsg, redirectURL string, token string) {
url := "http://" + sat.API.Console.Listener.Addr().String() + "/invited?invite=" + token
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
require.NoError(t, err, testMsg)
result, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusTemporaryRedirect, result.StatusCode, testMsg)
require.Equal(t, redirectURL, result.Header.Get("Location"), testMsg)
require.NoError(t, result.Body.Close(), testMsg)
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
baseURL := "http://" + sat.API.Console.Listener.Addr().String() + "/"
loginURL := baseURL + "login"
invalidURL := loginURL + "?invite_invalid=true"
tokenInvalidProj, err := service.CreateInviteToken(ctx1, project.ID, user2.Email, time.Now())
require.NoError(t, err)
token, err := service.CreateInviteToken(ctx1, project.PublicID, user2.Email, time.Now())
require.NoError(t, err)
checkInvitedRedirect("Invited - Invalid projectID", invalidURL, tokenInvalidProj)
checkInvitedRedirect("Invited - User not invited", invalidURL, token)
_, err = service.InviteProjectMembers(ctx1, project.ID, []string{user2.Email})
require.NoError(t, err)
token, err = service.CreateInviteToken(ctx1, project.PublicID, user2.Email, time.Now())
require.NoError(t, err)
// valid invite should redirect to login page with email.
checkInvitedRedirect("Invited - User invited", loginURL+"?email="+user2.Email, token)
})
}
func TestUserIDRateLimiter(t *testing.T) {
numLimits := 2
testplanet.Run(t, testplanet.Config{

View File

@ -3631,8 +3631,7 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
}
signIn := fmt.Sprintf("%s/login", s.satelliteAddress)
inviteTokens := make(map[string]string)
// add project invites in transaction scope
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
for _, invited := range users {
@ -3648,6 +3647,11 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
}
return err
}
token, err := s.CreateInviteToken(ctx, isMember.project.PublicID, invited.Email, invite.CreatedAt.Add(s.config.ProjectInvitationExpiration))
if err != nil {
return err
}
inviteTokens[invited.Email] = token
invites = append(invites, *invite)
}
return nil
@ -3656,7 +3660,10 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
return nil, Error.Wrap(err)
}
baseLink := fmt.Sprintf("%s/invited", s.satelliteAddress)
for _, invited := range users {
inviteLink := fmt.Sprintf("%s?invite=%s", baseLink, inviteTokens[invited.Email])
userName := invited.ShortName
if userName == "" {
userName = invited.FullName
@ -3667,10 +3674,109 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
&ExistingUserProjectInvitationEmail{
InviterEmail: user.Email,
Region: s.satelliteName,
SignInLink: fmt.Sprintf("%s?email=%s", signIn, invited.Email),
SignInLink: inviteLink,
},
)
}
return invites, nil
}
// GetInviteByToken returns a project invite given an invite token.
func (s *Service) GetInviteByToken(ctx context.Context, token string) (invite *ProjectInvitation, err error) {
defer mon.Task()(&ctx)(&err)
publicProjectID, email, err := s.ParseInviteToken(ctx, token)
if err != nil {
return nil, ErrProjectInviteInvalid.Wrap(err)
}
project, err := s.store.Projects().GetByPublicID(ctx, publicProjectID)
if err != nil {
if !errs.Is(err, sql.ErrNoRows) {
return nil, Error.Wrap(err)
}
return nil, ErrProjectInviteInvalid.New(projInviteInvalidErrMsg)
}
invite, err = s.store.ProjectInvitations().Get(ctx, project.ID, email)
if err != nil {
if !errs.Is(err, sql.ErrNoRows) {
return nil, Error.Wrap(err)
}
return nil, ErrProjectInviteInvalid.New(projInviteInvalidErrMsg)
}
if time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
err = s.store.ProjectInvitations().Delete(ctx, invite.ProjectID, invite.Email)
if err != nil {
s.log.Warn("error deleting expired project invitations",
zap.Error(err),
zap.String("email", email),
zap.String("projectID", project.ID.String()),
)
}
return nil, ErrProjectInviteInvalid.New(projInviteInvalidErrMsg)
}
return invite, nil
}
// CreateInviteToken creates a token for project invite links.
func (s *Service) CreateInviteToken(ctx context.Context, publicProjectID uuid.UUID, email string, inviteDate time.Time) (_ string, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "create invite token", zap.String("projectID", publicProjectID.String()), zap.String("email", email))
if err != nil {
return "", Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, user.ID, publicProjectID)
if err != nil {
return "", Error.Wrap(err)
}
linkClaims := consoleauth.Claims{
ID: publicProjectID,
Email: email,
Expiration: inviteDate.Add(s.config.ProjectInvitationExpiration),
}
claimJson, err := linkClaims.JSON()
if err != nil {
return "", err
}
token := consoleauth.Token{Payload: claimJson}
signature, err := s.tokens.SignToken(token)
if err != nil {
return "", err
}
token.Signature = signature
return token.String(), nil
}
// ParseInviteToken parses a token from project invite links.
func (s *Service) ParseInviteToken(ctx context.Context, token string) (publicID uuid.UUID, email string, err error) {
defer mon.Task()(&ctx)(&err)
parsedToken, err := consoleauth.FromBase64URLString(token)
valid, err := s.tokens.ValidateToken(parsedToken)
if err != nil {
return uuid.UUID{}, "", err
}
if !valid {
return uuid.UUID{}, "", ErrTokenInvalid.New("incorrect signature")
}
claims, err := consoleauth.FromJSON(parsedToken.Payload)
if err != nil {
return uuid.UUID{}, "", ErrTokenInvalid.New("JSON decoder: %w", err)
}
if time.Now().After(claims.Expiration) {
return uuid.UUID{}, "", ErrTokenExpiration.New("invite token expired")
}
return claims.ID, claims.Email, nil
}

View File

@ -2078,6 +2078,82 @@ func TestProjectInvitations(t *testing.T) {
require.Empty(t, invites)
})
t.Run("invite tokens", func(t *testing.T) {
user, ctx1 := getUserAndCtx(t)
_, ctx2 := getUserAndCtx(t)
project, err := sat.AddProject(ctx1, user.ID, "Test Project")
require.NoError(t, err)
_, err = service.CreateInviteToken(ctx2, project.PublicID, email, time.Now())
require.Error(t, err)
require.True(t, console.ErrNoMembership.Has(err))
_, err = service.CreateInviteToken(ctx1, testrand.UUID(), email, time.Now())
require.Error(t, err)
require.ErrorIs(t, err, sql.ErrNoRows)
someToken, err := service.CreateInviteToken(ctx1, project.PublicID, email, time.Now())
require.NoError(t, err)
require.NotEmpty(t, someToken)
id, mail, err := service.ParseInviteToken(ctx1, someToken)
require.NoError(t, err)
require.Equal(t, project.PublicID, id)
require.Equal(t, email, mail)
someToken, err = service.CreateInviteToken(ctx1, project.PublicID, email, time.Now().Add(-360*time.Hour))
require.NoError(t, err)
require.NotEmpty(t, someToken)
_, _, err = service.ParseInviteToken(ctx, someToken)
require.Error(t, err)
require.True(t, console.ErrTokenExpiration.Has(err))
})
t.Run("get invite by invite token", func(t *testing.T) {
owner, ctx := getUserAndCtx(t)
user, _ := getUserAndCtx(t)
project, err := sat.AddProject(ctx, owner.ID, "Test Project")
require.NoError(t, err)
invite := addInvite(t, ctx, project, user.Email, time.Now())
someToken, err := service.CreateInviteToken(ctx, project.PublicID, "some@email.com", invite.CreatedAt)
require.NoError(t, err)
inviteFromToken, err := service.GetInviteByToken(ctx, someToken)
require.Error(t, err)
require.True(t, console.ErrProjectInviteInvalid.Has(err))
require.Nil(t, inviteFromToken)
inviteToken, err := service.CreateInviteToken(ctx, project.PublicID, user.Email, invite.CreatedAt)
require.NoError(t, err)
inviteFromToken, err = service.GetInviteByToken(ctx, inviteToken)
require.NoError(t, err)
require.NotNil(t, inviteFromToken)
require.Equal(t, invite, inviteFromToken)
setInviteDate(ctx, invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration))
invites, err := service.GetUserProjectInvitations(ctx)
require.NoError(t, err)
require.Empty(t, invites)
_, err = service.GetInviteByToken(ctx, inviteToken)
require.Error(t, err)
require.True(t, console.ErrProjectInviteInvalid.Has(err))
// invalid project id. GetInviteByToken supports only public ids.
someToken, err = service.CreateInviteToken(ctx, project.ID, user.Email, invite.CreatedAt)
require.NoError(t, err)
_, err = service.GetInviteByToken(ctx, someToken)
require.Error(t, err)
require.True(t, console.ErrProjectInviteInvalid.Has(err))
})
t.Run("accept invitation", func(t *testing.T) {
user, ctx := getUserAndCtx(t)
proj := addProject(t, ctx)

View File

@ -13,6 +13,11 @@
<template v-else><b>Oops!</b> This account has already been verified.</template>
</p>
</div>
<div v-if="inviteInvalid" class="login-area__content-area__activation-banner error">
<p class="login-area__content-area__activation-banner__message">
<b>Oops!</b> The invite link you used has expired or is invalid.
</p>
</div>
<div class="login-area__content-area__container">
<div class="login-area__content-area__container__title-area">
<h1 class="login-area__content-area__container__title-area__title" aria-roledescription="sign-in-title">Sign In</h1>
@ -197,6 +202,7 @@ const isBadLoginMessageShown = ref(false);
const isDropdownShown = ref(false);
const pathEmail = ref<string | null>(null);
const inviteInvalid = ref(false);
const returnURL = ref(RouteConfig.ProjectDashboard.path);
@ -242,6 +248,7 @@ const captchaConfig = computed((): MultiCaptchaConfig => {
* Makes activated banner visible on successful account activation.
*/
onMounted(() => {
inviteInvalid.value = (route.query.invite_invalid as string ?? null) === 'true';
pathEmail.value = route.query.email as string ?? null;
if (pathEmail.value) {
setEmail(pathEmail.value);