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))
|
||||
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()
|
||||
|
@ -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{
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user