From c0ab7070e56f1de390d2e7d6a95083137041a572 Mon Sep 17 00:00:00 2001 From: Jack Hay Date: Thu, 31 Aug 2023 12:26:13 -0400 Subject: [PATCH] Update team invitation email link (#26550) Co-authored-by: Kyle D Co-authored-by: Jonathan Tran --- routers/web/auth/auth.go | 11 + services/auth/middleware.go | 4 +- services/mailer/mail_team_invite.go | 19 ++ templates/mail/team_invite.tmpl | 3 +- tests/integration/org_team_invite_test.go | 319 +++++++++++++++++++++- 5 files changed, 346 insertions(+), 10 deletions(-) diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 3bf133f562..c20a45ebc9 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -398,6 +398,11 @@ func SignUp(ctx *context.Context) { // Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + ctx.HTML(http.StatusOK, tplSignUp) } @@ -729,6 +734,12 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { } ctx.Flash.Success(ctx.Tr("auth.account_activated")) + if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + ctx.Redirect(setting.AppSubURL + "/") } diff --git a/services/auth/middleware.go b/services/auth/middleware.go index d1955a4c90..4a0b613fa6 100644 --- a/services/auth/middleware.go +++ b/services/auth/middleware.go @@ -120,9 +120,9 @@ func VerifyAuthWithOptions(options *VerifyOptions) func(ctx *context.Context) { } } - // Redirect to dashboard if user tries to visit any non-login page. + // Redirect to dashboard (or alternate location) if user tries to visit any non-login page. if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" { - ctx.Redirect(setting.AppSubURL + "/") + ctx.RedirectToFirst(ctx.FormString("redirect_to")) return } diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go index b6f47ee921..1403923c79 100644 --- a/services/mailer/mail_team_invite.go +++ b/services/mailer/mail_team_invite.go @@ -6,6 +6,8 @@ package mailer import ( "bytes" "context" + "fmt" + "net/url" org_model "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" @@ -33,6 +35,22 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod locale := translation.NewLocale(inviter.Language) + // check if a user with this email already exists + user, err := user_model.GetUserByEmail(ctx, invite.Email) + if err != nil && !user_model.IsErrUserNotExist(err) { + return err + } else if user != nil && user.ProhibitLogin { + return fmt.Errorf("login is prohibited for the invited user") + } + + inviteRedirect := url.QueryEscape(fmt.Sprintf("/org/invite/%s", invite.Token)) + inviteURL := fmt.Sprintf("%suser/sign_up?redirect_to=%s", setting.AppURL, inviteRedirect) + + if err == nil && user != nil { + // user account exists + inviteURL = fmt.Sprintf("%suser/login?redirect_to=%s", setting.AppURL, inviteRedirect) + } + subject := locale.Tr("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName()) mailMeta := map[string]any{ "Inviter": inviter, @@ -40,6 +58,7 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod "Team": team, "Invite": invite, "Subject": subject, + "InviteURL": inviteURL, // helper "locale": locale, "Str2html": templates.Str2html, diff --git a/templates/mail/team_invite.tmpl b/templates/mail/team_invite.tmpl index 8357895265..d21b7843ec 100644 --- a/templates/mail/team_invite.tmpl +++ b/templates/mail/team_invite.tmpl @@ -4,10 +4,9 @@ -{{$invite_url := printf "%sorg/invite/%s" AppUrl (QueryEscape .Invite.Token)}}

{{.locale.Tr "mail.team_invite.text_1" (DotEscape .Inviter.DisplayName) (DotEscape .Team.Name) (DotEscape .Organization.DisplayName) | Str2html}}

-

{{.locale.Tr "mail.team_invite.text_2"}}

{{$invite_url}}

+

{{.locale.Tr "mail.team_invite.text_2"}}

{{.InviteURL}}

{{.locale.Tr "mail.link_not_working_do_paste"}}

{{.locale.Tr "mail.team_invite.text_3" .Invite.Email}}

diff --git a/tests/integration/org_team_invite_test.go b/tests/integration/org_team_invite_test.go index 4d848dfc60..919769a61a 100644 --- a/tests/integration/org_team_invite_test.go +++ b/tests/integration/org_team_invite_test.go @@ -6,6 +6,8 @@ package integration import ( "fmt" "net/http" + "net/url" + "strings" "testing" "code.gitea.io/gitea/models/db" @@ -37,9 +39,9 @@ func TestOrgTeamEmailInvite(t *testing.T) { session := loginUser(t, "user1") - url := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) - csrf := GetCSRF(t, session, url) - req := NewRequestWithValues(t, "POST", url+"/action/add", map[string]string{ + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + csrf := GetCSRF(t, session, teamURL) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ "_csrf": csrf, "uid": "1", "uname": user.Email, @@ -56,9 +58,9 @@ func TestOrgTeamEmailInvite(t *testing.T) { session = loginUser(t, user.Name) // join the team - url = fmt.Sprintf("/org/invite/%s", invites[0].Token) - csrf = GetCSRF(t, session, url) - req = NewRequestWithValues(t, "POST", url, map[string]string{ + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + csrf = GetCSRF(t, session, inviteURL) + req = NewRequestWithValues(t, "POST", inviteURL, map[string]string{ "_csrf": csrf, }) resp = session.MakeRequest(t, req, http.StatusSeeOther) @@ -69,3 +71,308 @@ func TestOrgTeamEmailInvite(t *testing.T) { assert.NoError(t, err) assert.True(t, isMember) } + +// Check that users are redirected to accept the invitation correctly after login +func TestOrgTeamEmailInviteRedirectsExistingUser(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + + defer tests.PrepareTestEnv(t)() + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + assert.NoError(t, err) + assert.False(t, isMember) + + // create the invite + session := loginUser(t, "user1") + + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ + "_csrf": GetCSRF(t, session, teamURL), + "uid": "1", + "uname": user.Email, + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the invite token + invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) + assert.NoError(t, err) + assert.Len(t, invites, 1) + + // accept the invite + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + req = NewRequest(t, "GET", fmt.Sprintf("/user/login?redirect_to=%s", url.QueryEscape(inviteURL))) + resp = MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": doc.GetCSRF(), + "user_name": "user5", + "password": "password", + }) + for _, c := range resp.Result().Cookies() { + req.AddCookie(c) + } + + resp = MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, inviteURL, test.RedirectURL(resp)) + + // complete the login process + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + + session = emptyTestSession(t) + baseURL, err := url.Parse(setting.AppURL) + assert.NoError(t, err) + session.jar.SetCookies(baseURL, cr.Cookies()) + + // make the request + req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{ + "_csrf": GetCSRF(t, session, test.RedirectURL(resp)), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + assert.NoError(t, err) + assert.True(t, isMember) +} + +// Check that newly signed up users are redirected to accept the invitation correctly +func TestOrgTeamEmailInviteRedirectsNewUser(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + + defer tests.PrepareTestEnv(t)() + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + + // create the invite + session := loginUser(t, "user1") + + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ + "_csrf": GetCSRF(t, session, teamURL), + "uid": "1", + "uname": "doesnotexist@example.com", + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the invite token + invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) + assert.NoError(t, err) + assert.Len(t, invites, 1) + + // accept the invite + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + req = NewRequest(t, "GET", fmt.Sprintf("/user/sign_up?redirect_to=%s", url.QueryEscape(inviteURL))) + resp = MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "_csrf": doc.GetCSRF(), + "user_name": "doesnotexist", + "email": "doesnotexist@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + for _, c := range resp.Result().Cookies() { + req.AddCookie(c) + } + + resp = MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, inviteURL, test.RedirectURL(resp)) + + // complete the signup process + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + + session = emptyTestSession(t) + baseURL, err := url.Parse(setting.AppURL) + assert.NoError(t, err) + session.jar.SetCookies(baseURL, cr.Cookies()) + + // make the redirected request + req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{ + "_csrf": GetCSRF(t, session, test.RedirectURL(resp)), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the new user + newUser, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist") + assert.NoError(t, err) + + isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, newUser.ID) + assert.NoError(t, err) + assert.True(t, isMember) +} + +// Check that users are redirected correctly after confirming their email +func TestOrgTeamEmailInviteRedirectsNewUserWithActivation(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + + // enable email confirmation temporarily + defer func(prevVal bool) { + setting.Service.RegisterEmailConfirm = prevVal + }(setting.Service.RegisterEmailConfirm) + setting.Service.RegisterEmailConfirm = true + + defer tests.PrepareTestEnv(t)() + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + + // create the invite + session := loginUser(t, "user1") + + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ + "_csrf": GetCSRF(t, session, teamURL), + "uid": "1", + "uname": "doesnotexist@example.com", + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the invite token + invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) + assert.NoError(t, err) + assert.Len(t, invites, 1) + + // accept the invite + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + req = NewRequest(t, "GET", fmt.Sprintf("/user/sign_up?redirect_to=%s", url.QueryEscape(inviteURL))) + inviteResp := MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "_csrf": doc.GetCSRF(), + "user_name": "doesnotexist", + "email": "doesnotexist@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + for _, c := range inviteResp.Result().Cookies() { + req.AddCookie(c) + } + + resp = MakeRequest(t, req, http.StatusOK) + + user, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist") + assert.NoError(t, err) + + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + + session = emptyTestSession(t) + baseURL, err := url.Parse(setting.AppURL) + assert.NoError(t, err) + session.jar.SetCookies(baseURL, cr.Cookies()) + + activateURL := fmt.Sprintf("/user/activate?code=%s", user.GenerateEmailActivateCode("doesnotexist@example.com")) + req = NewRequestWithValues(t, "POST", activateURL, map[string]string{ + "password": "examplePassword!1", + }) + + // use the cookies set by the signup request + for _, c := range inviteResp.Result().Cookies() { + req.AddCookie(c) + } + + resp = session.MakeRequest(t, req, http.StatusSeeOther) + // should be redirected to accept the invite + assert.Equal(t, inviteURL, test.RedirectURL(resp)) + + req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{ + "_csrf": GetCSRF(t, session, test.RedirectURL(resp)), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + assert.NoError(t, err) + assert.True(t, isMember) +} + +// Test that a logged-in user who navigates to the sign-up link is then redirected using redirect_to +// For example: an invite may have been created before the user account was created, but they may be +// accepting the invite after having created an account separately +func TestOrgTeamEmailInviteRedirectsExistingUserWithLogin(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + + defer tests.PrepareTestEnv(t)() + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + assert.NoError(t, err) + assert.False(t, isMember) + + // create the invite + session := loginUser(t, "user1") + + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ + "_csrf": GetCSRF(t, session, teamURL), + "uid": "1", + "uname": user.Email, + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the invite token + invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) + assert.NoError(t, err) + assert.Len(t, invites, 1) + + // note: the invited user has logged in + session = loginUser(t, "user5") + + // accept the invite (note: this uses the sign_up url) + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + req = NewRequest(t, "GET", fmt.Sprintf("/user/sign_up?redirect_to=%s", url.QueryEscape(inviteURL))) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, inviteURL, test.RedirectURL(resp)) + + // make the request + req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{ + "_csrf": GetCSRF(t, session, test.RedirectURL(resp)), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + assert.NoError(t, err) + assert.True(t, isMember) +}