diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 1a1b8d3b6..b2ea1a2a0 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -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() diff --git a/satellite/console/consoleweb/server_test.go b/satellite/console/consoleweb/server_test.go index f444d230c..577367f0b 100644 --- a/satellite/console/consoleweb/server_test.go +++ b/satellite/console/consoleweb/server_test.go @@ -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{ diff --git a/satellite/console/service.go b/satellite/console/service.go index 4d61c4b0c..759611890 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -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 +} diff --git a/satellite/console/service_test.go b/satellite/console/service_test.go index 777fe0904..14dc4cf5b 100644 --- a/satellite/console/service_test.go +++ b/satellite/console/service_test.go @@ -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) diff --git a/web/satellite/src/views/LoginArea.vue b/web/satellite/src/views/LoginArea.vue index 8201b532b..d6b860c8b 100644 --- a/web/satellite/src/views/LoginArea.vue +++ b/web/satellite/src/views/LoginArea.vue @@ -13,6 +13,11 @@

+
+

+ Oops! The invite link you used has expired or is invalid. +

+

Sign In

@@ -197,6 +202,7 @@ const isBadLoginMessageShown = ref(false); const isDropdownShown = ref(false); const pathEmail = ref(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);