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:
parent
6268c75d3f
commit
dbd575e50b
@ -357,6 +357,8 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
|
|||||||
fs := http.FileServer(http.Dir(server.config.StaticDir))
|
fs := http.FileServer(http.Dir(server.config.StaticDir))
|
||||||
router.PathPrefix("/static/").Handler(server.brotliMiddleware(http.StripPrefix("/static", fs)))
|
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
|
// These paths previously required a trailing slash, so we support both forms for now
|
||||||
slashRouter := router.NewRoute().Subrouter()
|
slashRouter := router.NewRoute().Subrouter()
|
||||||
slashRouter.StrictSlash(true)
|
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)
|
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.
|
// graphqlHandler is graphql endpoint http handler function.
|
||||||
func (server *Server) graphqlHandler(w http.ResponseWriter, r *http.Request) {
|
func (server *Server) graphqlHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
@ -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) {
|
func TestUserIDRateLimiter(t *testing.T) {
|
||||||
numLimits := 2
|
numLimits := 2
|
||||||
testplanet.Run(t, testplanet.Config{
|
testplanet.Run(t, testplanet.Config{
|
||||||
|
@ -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
|
// add project invites in transaction scope
|
||||||
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
|
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
|
||||||
for _, invited := range users {
|
for _, invited := range users {
|
||||||
@ -3648,6 +3647,11 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
|
|||||||
}
|
}
|
||||||
return err
|
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)
|
invites = append(invites, *invite)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -3656,7 +3660,10 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
|
|||||||
return nil, Error.Wrap(err)
|
return nil, Error.Wrap(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
baseLink := fmt.Sprintf("%s/invited", s.satelliteAddress)
|
||||||
for _, invited := range users {
|
for _, invited := range users {
|
||||||
|
inviteLink := fmt.Sprintf("%s?invite=%s", baseLink, inviteTokens[invited.Email])
|
||||||
|
|
||||||
userName := invited.ShortName
|
userName := invited.ShortName
|
||||||
if userName == "" {
|
if userName == "" {
|
||||||
userName = invited.FullName
|
userName = invited.FullName
|
||||||
@ -3667,10 +3674,109 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
|
|||||||
&ExistingUserProjectInvitationEmail{
|
&ExistingUserProjectInvitationEmail{
|
||||||
InviterEmail: user.Email,
|
InviterEmail: user.Email,
|
||||||
Region: s.satelliteName,
|
Region: s.satelliteName,
|
||||||
SignInLink: fmt.Sprintf("%s?email=%s", signIn, invited.Email),
|
SignInLink: inviteLink,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return invites, nil
|
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
|
||||||
|
}
|
||||||
|
@ -2078,6 +2078,82 @@ func TestProjectInvitations(t *testing.T) {
|
|||||||
require.Empty(t, invites)
|
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) {
|
t.Run("accept invitation", func(t *testing.T) {
|
||||||
user, ctx := getUserAndCtx(t)
|
user, ctx := getUserAndCtx(t)
|
||||||
proj := addProject(t, ctx)
|
proj := addProject(t, ctx)
|
||||||
|
@ -13,6 +13,11 @@
|
|||||||
<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 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">
|
||||||
<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>
|
||||||
@ -197,6 +202,7 @@ const isBadLoginMessageShown = ref(false);
|
|||||||
const isDropdownShown = ref(false);
|
const isDropdownShown = ref(false);
|
||||||
|
|
||||||
const pathEmail = ref<string | null>(null);
|
const pathEmail = ref<string | null>(null);
|
||||||
|
const inviteInvalid = ref(false);
|
||||||
|
|
||||||
const returnURL = ref(RouteConfig.ProjectDashboard.path);
|
const returnURL = ref(RouteConfig.ProjectDashboard.path);
|
||||||
|
|
||||||
@ -242,6 +248,7 @@ const captchaConfig = computed((): MultiCaptchaConfig => {
|
|||||||
* Makes activated banner visible on successful account activation.
|
* Makes activated banner visible on successful account activation.
|
||||||
*/
|
*/
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
inviteInvalid.value = (route.query.invite_invalid as string ?? null) === 'true';
|
||||||
pathEmail.value = route.query.email as string ?? null;
|
pathEmail.value = route.query.email as string ?? null;
|
||||||
if (pathEmail.value) {
|
if (pathEmail.value) {
|
||||||
setEmail(pathEmail.value);
|
setEmail(pathEmail.value);
|
||||||
|
Loading…
Reference in New Issue
Block a user