From 430ac31dd3fd101bc343bf823222f49c0244e7ce Mon Sep 17 00:00:00 2001 From: Patrick Deziel Date: Tue, 15 Nov 2022 14:34:59 -0600 Subject: [PATCH 1/5] send invite email on add collaborator --- pkg/bff/auth/authtest/server.go | 52 +++++++++++++++++++++++++ pkg/bff/collaborators.go | 68 ++++++++++++++++++++++++++++++++- pkg/bff/collaborators_test.go | 5 +++ pkg/bff/config/config.go | 22 +++++++++++ pkg/bff/config/config_test.go | 12 ++++++ pkg/bff/errors.go | 2 + pkg/bff/server.go | 8 +++- pkg/bff/server_test.go | 3 +- pkg/bff/status_test.go | 1 + pkg/bff/users.go | 18 +++++++++ 10 files changed, 187 insertions(+), 4 deletions(-) diff --git a/pkg/bff/auth/authtest/server.go b/pkg/bff/auth/authtest/server.go index a44b8a4a7..bc643117c 100644 --- a/pkg/bff/auth/authtest/server.go +++ b/pkg/bff/auth/authtest/server.go @@ -20,6 +20,7 @@ import ( "crypto/rsa" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -37,6 +38,7 @@ const ( ClientID = "a5laOSr0NOX1L53yBaNtumKOoExFxptc" ClientSecret = "me4JZSvBvPSnBaM0h0AoXgXPn1VBiBMz0bL7E/sV1isndP9lZ5ptm5NWA9IkKwEb" Audience = "http://localhost" + RedirectURL = "https://localhost/auth/callback" Name = "Leopold Wentzel" Email = "leopold.wentzel@gmail.com" UserID = "test|abcdefg1234567890" @@ -116,6 +118,8 @@ func New() (s *Server, err error) { s.mux.HandleFunc("/api/v2/users/"+UserID, s.Users) s.mux.HandleFunc("/api/v2/users/"+UserID+"/roles", s.UserRoles) s.mux.HandleFunc("/api/v2/roles", s.Roles) + s.mux.HandleFunc("/api/v2/users-by-email", s.ListUsers) + s.mux.HandleFunc("/api/v2/tickets/password-change", s.GenerateTicket) s.srv = httptest.NewTLSServer(s.mux) s.URL, _ = url.Parse(s.srv.URL) @@ -127,6 +131,7 @@ func (s *Server) Config() config.AuthConfig { return config.AuthConfig{ Domain: s.URL.Host, Audience: Audience, + RedirectURL: RedirectURL, ProviderCache: 30 * time.Second, ClientID: ClientID, ClientSecret: ClientSecret, @@ -228,6 +233,8 @@ func (s *Server) Users(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.GetUser(w, r) + case http.MethodPost: + s.CreateUser(w, r) case http.MethodPatch: s.PatchUserAppMetadata(w, r) default: @@ -246,6 +253,22 @@ func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) CreateUser(w http.ResponseWriter, r *http.Request) { + // Create a new user object + user := &management.User{} + if err := json.NewDecoder(r.Body).Decode(user); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Add the user to the map + id := UserID + user.ID = &id + s.users[*user.ID] = user + + w.WriteHeader(http.StatusCreated) +} + func (s *Server) UserRoles(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -259,6 +282,20 @@ func (s *Server) UserRoles(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) ListUsers(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + users := make([]*management.User, 0) + for _, user := range s.users { + users = append(users, user) + } + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(users) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + func (s *Server) ListUserRoles(w http.ResponseWriter, r *http.Request) { // Return the roles object from the map // TODO: Parse the user id from the request @@ -270,6 +307,21 @@ func (s *Server) ListUserRoles(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) GenerateTicket(w http.ResponseWriter, r *http.Request) { + // Create a new ticket object + ticket := &management.Ticket{} + if err := json.NewDecoder(r.Body).Decode(ticket); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + url := fmt.Sprintf("https://example.com/tickets/1234") + ticket.Ticket = &url + + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(ticket) +} + type RoleParams struct { Roles []string `json:"roles"` } diff --git a/pkg/bff/collaborators.go b/pkg/bff/collaborators.go index 7b9dfcf8c..327ae21f4 100644 --- a/pkg/bff/collaborators.go +++ b/pkg/bff/collaborators.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "sort" "time" @@ -23,6 +24,7 @@ func (s *Server) AddCollaborator(c *gin.Context) { var ( err error collaborator *models.Collaborator + inviter *management.User org *models.Organization ) @@ -32,6 +34,13 @@ func (s *Server) AddCollaborator(c *gin.Context) { return } + // The invoking user is the inviter + if inviter, err = auth.GetUserInfo(c); err != nil { + log.Error().Err(err).Msg("add collaborator handler requires user info; expected middleware to return 401") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not identify user")) + return + } + // Unmarshal the collaborator from the POST request collaborator = &models.Collaborator{} if err = c.ShouldBind(collaborator); err != nil { @@ -66,8 +75,63 @@ func (s *Server) AddCollaborator(c *gin.Context) { } org.Collaborators[id] = collaborator - // TODO: We can search the email address in Auth0 to see if the user already exists - // TODO: Send invite/verification email to the collaborator + // If the user doesn't exist in Auth0 then we need to create them + var user *management.User + if user, err = s.FindUserByEmail(collaborator.Email); err != nil { + if !errors.Is(err, ErrUserEmailNotFound) { + log.Error().Err(err).Str("email", collaborator.Email).Msg("error finding user by email in Auth0") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) + return + } + + // Create an unverified user in Auth0 + // Note: If the user has already registered with a social IDP then they will need to link accounts + var verified bool + connection := "Username-Password-Authentication" + user.Email = &collaborator.Email + user.Connection = &connection + user.EmailVerified = &verified + if err = s.auth0.User.Create(user); err != nil { + log.Error().Err(err).Str("email", collaborator.Email).Msg("error creating user in Auth0") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) + return + } + } + + // Generate the user verification link containing a redirect URL with the org ID + redirectURL := fmt.Sprintf("%s?orgid=%s", s.conf.Auth0.RedirectURL, org.Id) + expiration := int((time.Hour * 24 * 7).Seconds()) // 7 days + ticket := &management.Ticket{ + UserID: user.ID, + ResultURL: &redirectURL, + TTLSec: &expiration, + MarkEmailAsVerified: user.EmailVerified, + } + if err = s.auth0.Ticket.ChangePassword(ticket); err != nil { + log.Error().Err(err).Str("email", collaborator.Email).Msg("error creating password change ticket in Auth0") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) + return + } + if ticket.Ticket == nil { + log.Error().Err(err).Str("user_id", *user.ID).Msg("could not retrieve password change ticket URL from Auth0") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) + return + } + + // Parse the ticket string into a URL + var inviteURL *url.URL + if inviteURL, err = url.Parse(*ticket.Ticket); err != nil { + log.Error().Err(err).Str("ticket", *ticket.Ticket).Msg("could not parse password change ticket URL") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) + return + } + + // Send the verification email to the user + if err = s.email.SendUserInvite(user, inviter, org, inviteURL); err != nil { + log.Error().Err(err).Str("email", *user.Email).Msg("error sending user invite email") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) + return + } // Save the updated organization if err = s.db.UpdateOrganization(org); err != nil { diff --git a/pkg/bff/collaborators_test.go b/pkg/bff/collaborators_test.go index 061580c75..a4c31fa93 100644 --- a/pkg/bff/collaborators_test.go +++ b/pkg/bff/collaborators_test.go @@ -10,12 +10,14 @@ import ( "github.com/trisacrypto/directory/pkg/bff/auth" "github.com/trisacrypto/directory/pkg/bff/auth/authtest" "github.com/trisacrypto/directory/pkg/bff/models/v1" + "github.com/trisacrypto/directory/pkg/utils/emails/mock" "google.golang.org/protobuf/proto" ) func (s *bffTestSuite) TestAddCollaborator() { require := s.Require() defer s.ResetDB() + defer mock.PurgeEmails() // Create initial claims fixture claims := &authtest.Claims{ @@ -78,6 +80,9 @@ func (s *bffTestSuite) TestAddCollaborator() { require.NotEmpty(collab.CreatedAt, "expected collaborator to have a created at timestamp") require.False(collab.Verified, "expected collaborator to not be verified") + // Email should be sent to the collaborator + require.Len(mock.Emails, 1, "expected one email to be sent") + // Should return an error if the collaborator already exists _, err = s.client.AddCollaborator(context.TODO(), request) s.requireError(err, http.StatusConflict, "collaborator already exists", "expected error when collaborator already exists") diff --git a/pkg/bff/config/config.go b/pkg/bff/config/config.go index a5b110c8c..9bbaa8a8a 100644 --- a/pkg/bff/config/config.go +++ b/pkg/bff/config/config.go @@ -48,6 +48,7 @@ type AuthConfig struct { Domain string `split_words:"true" required:"true"` Issuer string `split_words:"true" required:"false"` // Set to the custom domain if enabled in Auth0 (ensure trailing slash is set if required!) Audience string `split_words:"true" required:"true"` + RedirectURL string `split_words:"true" required:"true"` ProviderCache time.Duration `split_words:"true" default:"5m"` ClientID string `split_words:"true"` ClientSecret string `split_words:"true"` @@ -170,6 +171,10 @@ func (c AuthConfig) Validate() error { return err } + if err := c.Redirect(); err != nil { + return err + } + if c.ProviderCache == 0 { return errors.New("invalid configuration: auth0 provider cache duration should be longer than 0") } @@ -213,6 +218,23 @@ func (c AuthConfig) IssuerURL() (u *url.URL, err error) { return u, nil } +func (c AuthConfig) Redirect() (err error) { + if c.RedirectURL == "" { + return errors.New("invalid configuration: auth0 redirect url must be configured") + } + + // URL should not have a trailing slash + if strings.HasSuffix(c.RedirectURL, "/") { + return errors.New("invalid configuration: auth0 redirect url must not have a trailing slash") + } + + if _, err = url.Parse(c.RedirectURL); err != nil { + return errors.New("invalid configuration: auth0 redirect url must be a valid url") + } + + return nil +} + func (c AuthConfig) ClientCredentials() management.Option { return management.WithClientCredentials(c.ClientID, c.ClientSecret) } diff --git a/pkg/bff/config/config_test.go b/pkg/bff/config/config_test.go index bd803d0d3..dde036619 100644 --- a/pkg/bff/config/config_test.go +++ b/pkg/bff/config/config_test.go @@ -20,6 +20,7 @@ var testEnv = map[string]string{ "GDS_BFF_COOKIE_DOMAIN": "vaspdirectory.net", "GDS_BFF_AUTH0_DOMAIN": "example.auth0.com", "GDS_BFF_AUTH0_ISSUER": "https://auth.example.com", + "GDS_BFF_AUTH0_REDIRECT_URL": "https://vaspdirectory.net/auth/callback", "GDS_BFF_AUTH0_AUDIENCE": "https://vaspdirectory.net", "GDS_BFF_AUTH0_PROVIDER_CACHE": "10m", "GDS_BFF_AUTH0_CLIENT_ID": "exampleid", @@ -94,6 +95,7 @@ func TestConfig(t *testing.T) { require.Equal(t, testEnv["GDS_BFF_AUTH0_DOMAIN"], conf.Auth0.Domain) require.Equal(t, testEnv["GDS_BFF_AUTH0_ISSUER"], conf.Auth0.Issuer) require.Equal(t, testEnv["GDS_BFF_AUTH0_AUDIENCE"], conf.Auth0.Audience) + require.Equal(t, testEnv["GDS_BFF_AUTH0_REDIRECT_URL"], conf.Auth0.RedirectURL) require.Equal(t, testEnv["GDS_BFF_AUTH0_CLIENT_ID"], conf.Auth0.ClientID) require.Equal(t, testEnv["GDS_BFF_AUTH0_CLIENT_SECRET"], conf.Auth0.ClientSecret) require.True(t, conf.Auth0.Testing) @@ -143,6 +145,7 @@ func TestRequiredConfig(t *testing.T) { required := []string{ "GDS_BFF_AUTH0_DOMAIN", "GDS_BFF_AUTH0_AUDIENCE", + "GDS_BFF_AUTH0_REDIRECT_URL", "GDS_BFF_AUTH0_CLIENT_ID", "GDS_BFF_AUTH0_CLIENT_SECRET", "GDS_BFF_TESTNET_DATABASE_URL", @@ -200,6 +203,7 @@ func TestRequiredConfig(t *testing.T) { func TestAuthConfig(t *testing.T) { conf := config.AuthConfig{ Domain: "example.auth0.com", + RedirectURL: "https://vaspdirectory.net/auth/callback", Audience: "https://vaspdirectory.net", ProviderCache: 0, Testing: true, @@ -251,6 +255,14 @@ func TestAuthConfig(t *testing.T) { u, err := conf.IssuerURL() require.NoError(t, err, "could not parse issuer string") require.Equal(t, conf.Issuer, u.String()) + + // Test empty redirect URL + conf.RedirectURL = "" + require.EqualError(t, conf.Redirect(), "invalid configuration: auth0 redirect url must be configured") + + // Test invalid redirect URL + conf.RedirectURL = "https://vaspdirectory.net/auth/callback/" + require.EqualError(t, conf.Redirect(), "invalid configuration: auth0 redirect url must not have a trailing slash") } func TestMembersConfigValidation(t *testing.T) { diff --git a/pkg/bff/errors.go b/pkg/bff/errors.go index 87e9435dd..b2edb7e83 100644 --- a/pkg/bff/errors.go +++ b/pkg/bff/errors.go @@ -9,4 +9,6 @@ var ( ErrEmptyAnnouncement = errors.New("cannot post a zero-valued announcement") ErrUnboundedRecent = errors.New("cannot specify zero-valued not before otherwise announcements fetch is unbounded") ErrInvalidUserRole = errors.New("invalid user role specified") + ErrUserEmailNotFound = errors.New("could not find user by email address") + ErrMultipleEmailUsers = errors.New("multiple users found by email address") ) diff --git a/pkg/bff/server.go b/pkg/bff/server.go index 9a84f378d..71eb09090 100644 --- a/pkg/bff/server.go +++ b/pkg/bff/server.go @@ -20,6 +20,7 @@ import ( "github.com/trisacrypto/directory/pkg/bff/api/v1" "github.com/trisacrypto/directory/pkg/bff/auth" "github.com/trisacrypto/directory/pkg/bff/config" + "github.com/trisacrypto/directory/pkg/bff/emails" "github.com/trisacrypto/directory/pkg/store" "github.com/trisacrypto/directory/pkg/utils/logger" "github.com/trisacrypto/directory/pkg/utils/sentry" @@ -93,6 +94,10 @@ func New(conf config.Config) (s *Server, err error) { log.Debug().Str("dsn", s.conf.Database.URL).Bool("insecure", s.conf.Database.Insecure).Msg("connected to trtl database") } + if s.email, err = emails.New(conf.Email); err != nil { + return nil, fmt.Errorf("could not connect to email service: %s", err) + } + if s.auth0, err = auth.NewManagementClient(s.conf.Auth0); err != nil { return nil, fmt.Errorf("could not connect to auth0 management api: %s", err) } @@ -163,6 +168,7 @@ type Server struct { mainnetGDS GlobalDirectoryClient db store.Store auth0 *management.Management + email *emails.EmailManager started time.Time healthy bool url string @@ -350,7 +356,7 @@ func (s *Server) setupRoutes() (err error) { collaborators := v1.Group("/collaborators") { collaborators.GET("", auth.Authorize("read:collaborators"), s.ListCollaborators) - collaborators.POST("", auth.DoubleCookie(), auth.Authorize("update:collaborators"), s.AddCollaborator) + collaborators.POST("", auth.DoubleCookie(), auth.Authorize("update:collaborators"), userinfo, s.AddCollaborator) collaborators.POST("/:collabID", auth.DoubleCookie(), auth.Authorize("update:collaborators"), s.UpdateCollaboratorRoles) collaborators.DELETE("/:collabID", auth.DoubleCookie(), auth.Authorize("update:collaborators"), s.DeleteCollaborator) } diff --git a/pkg/bff/server_test.go b/pkg/bff/server_test.go index 17dfb7844..2b4757b66 100644 --- a/pkg/bff/server_test.go +++ b/pkg/bff/server_test.go @@ -102,7 +102,8 @@ func (s *bffTestSuite) SetupSuite() { Insecure: true, }, Email: config.EmailConfig{ - Testing: true, + ServiceEmail: "service@example.com", + Testing: true, }, }.Mark() require.NoError(err, "could not mark configuration") diff --git a/pkg/bff/status_test.go b/pkg/bff/status_test.go index 4476ee286..4558b2790 100644 --- a/pkg/bff/status_test.go +++ b/pkg/bff/status_test.go @@ -185,6 +185,7 @@ func (s *bffTestSuite) TestMaintenanceMode() { Auth0: config.AuthConfig{ Domain: "auth.localhost", Audience: "http://localhost", + RedirectURL: "http://localhost/auth/callback", ProviderCache: 5 * time.Minute, Testing: true, }, diff --git a/pkg/bff/users.go b/pkg/bff/users.go index edfbaf916..c95f738c2 100644 --- a/pkg/bff/users.go +++ b/pkg/bff/users.go @@ -3,6 +3,7 @@ package bff import ( "fmt" "net/http" + "strings" "time" "github.com/auth0/go-auth0/management" @@ -292,6 +293,23 @@ func (s *Server) ListUserRoles(c *gin.Context) { c.JSON(http.StatusOK, []string{CollaboratorRole, LeaderRole}) } +// FindUserByEmail returns the Auth0 user record by email address. +func (s *Server) FindUserByEmail(email string) (user *management.User, err error) { + var users []*management.User + if users, err = s.auth0.User.ListByEmail(strings.ToLower(email)); err != nil { + return nil, err + } + + switch len(users) { + case 0: + return nil, ErrUserEmailNotFound + case 1: + return users[0], nil + default: + return nil, ErrMultipleEmailUsers + } +} + func (s *Server) FindRoleByName(name string) (*management.Role, error) { roles, err := s.auth0.Role.List() if err != nil { From 4cfedf30380fdcf342eb84b06a183ba714dff697 Mon Sep 17 00:00:00 2001 From: Patrick Deziel Date: Thu, 17 Nov 2022 11:40:23 -0600 Subject: [PATCH 2/5] auth0 user invite tickets --- .env.template | 2 + containers/docker-compose.yaml | 2 + pkg/bff/auth/authtest/server.go | 43 +++++++++-------- pkg/bff/collaborators.go | 84 ++++++++++++++++++++++----------- pkg/bff/config/config.go | 21 +++++---- pkg/bff/config/config_test.go | 21 ++++++--- pkg/bff/status_test.go | 11 +++-- 7 files changed, 117 insertions(+), 67 deletions(-) diff --git a/.env.template b/.env.template index 329366368..0f21a1432 100644 --- a/.env.template +++ b/.env.template @@ -145,6 +145,8 @@ GDS_BFF_ALLOW_ORIGINS="http://localhost:3000,http://localhost:3003" GDS_BFF_COOKIE_DOMAIN=localhost GDS_BFF_AUTH0_DOMAIN= +GDS_BFF_AUTH0_CONNECTION_NAME= +GDS_BFF_AUTH0_REDIRECT_URL= GDS_BFF_AUTH0_ISSUER= GDS_BFF_AUTH0_AUDIENCE= GDS_BFF_AUTH0_CLIENT_ID= diff --git a/containers/docker-compose.yaml b/containers/docker-compose.yaml index 38c070c07..3e4280a72 100644 --- a/containers/docker-compose.yaml +++ b/containers/docker-compose.yaml @@ -171,6 +171,8 @@ services: - GDS_BFF_SENTRY_TRACK_PERFORMANCE - GDS_BFF_AUTH0_TESTING - GDS_BFF_AUTH0_DOMAIN=dev-bu-hbv3o.us.auth0.com + - GDS_BFF_AUTH0_CONNECTION_NAME=Username-Password-Authentication + - GDS_BFF_AUTH0_REDIRECT_URL=http://vaspdirectory.dev/auth/callback - GDS_BFF_AUTH0_ISSUER=https://auth.vaspdirectory.dev/ - GDS_BFF_AUTH0_AUDIENCE=https://bff.vaspdirectory.dev - GDS_BFF_AUTH0_CLIENT_ID diff --git a/pkg/bff/auth/authtest/server.go b/pkg/bff/auth/authtest/server.go index bc643117c..653a9de73 100644 --- a/pkg/bff/auth/authtest/server.go +++ b/pkg/bff/auth/authtest/server.go @@ -34,19 +34,20 @@ import ( ) const ( - KeyID = "StyqeY8Kl4Eam28KsUs" - ClientID = "a5laOSr0NOX1L53yBaNtumKOoExFxptc" - ClientSecret = "me4JZSvBvPSnBaM0h0AoXgXPn1VBiBMz0bL7E/sV1isndP9lZ5ptm5NWA9IkKwEb" - Audience = "http://localhost" - RedirectURL = "https://localhost/auth/callback" - Name = "Leopold Wentzel" - Email = "leopold.wentzel@gmail.com" - UserID = "test|abcdefg1234567890" - UserRole = "Organization Collaborator" - OrgID = "b1b9e9b1-9a44-4317-aefa-473971b4df42" - MainNetVASP = "87d92fd1-53cf-47d8-85b1-048e8a38ced9" - TestNetVASP = "d0082f55-d3ba-4726-a46d-85e3f5a2911f" - Scope = "openid profile email" + KeyID = "StyqeY8Kl4Eam28KsUs" + ClientID = "a5laOSr0NOX1L53yBaNtumKOoExFxptc" + ClientSecret = "me4JZSvBvPSnBaM0h0AoXgXPn1VBiBMz0bL7E/sV1isndP9lZ5ptm5NWA9IkKwEb" + Audience = "http://localhost" + ConnectionName = "Username-Password-Authentication" + RedirectURL = "https://localhost/auth/callback" + Name = "Leopold Wentzel" + Email = "leopold.wentzel@gmail.com" + UserID = "test|abcdefg1234567890" + UserRole = "Organization Collaborator" + OrgID = "b1b9e9b1-9a44-4317-aefa-473971b4df42" + MainNetVASP = "87d92fd1-53cf-47d8-85b1-048e8a38ced9" + TestNetVASP = "d0082f55-d3ba-4726-a46d-85e3f5a2911f" + Scope = "openid profile email" ) var ( @@ -120,6 +121,7 @@ func New() (s *Server, err error) { s.mux.HandleFunc("/api/v2/roles", s.Roles) s.mux.HandleFunc("/api/v2/users-by-email", s.ListUsers) s.mux.HandleFunc("/api/v2/tickets/password-change", s.GenerateTicket) + s.mux.HandleFunc("/api/v2/tickets/email-verification", s.GenerateTicket) s.srv = httptest.NewTLSServer(s.mux) s.URL, _ = url.Parse(s.srv.URL) @@ -129,13 +131,14 @@ func New() (s *Server, err error) { // Config returns an AuthConfig that can be used to setup middleware. func (s *Server) Config() config.AuthConfig { return config.AuthConfig{ - Domain: s.URL.Host, - Audience: Audience, - RedirectURL: RedirectURL, - ProviderCache: 30 * time.Second, - ClientID: ClientID, - ClientSecret: ClientSecret, - Testing: true, + Domain: s.URL.Host, + Audience: Audience, + ConnectionName: ConnectionName, + RedirectURL: RedirectURL, + ProviderCache: 30 * time.Second, + ClientID: ClientID, + ClientSecret: ClientSecret, + Testing: true, } } diff --git a/pkg/bff/collaborators.go b/pkg/bff/collaborators.go index 327ae21f4..177713175 100644 --- a/pkg/bff/collaborators.go +++ b/pkg/bff/collaborators.go @@ -14,6 +14,7 @@ import ( "github.com/trisacrypto/directory/pkg/bff/api/v1" "github.com/trisacrypto/directory/pkg/bff/auth" "github.com/trisacrypto/directory/pkg/bff/models/v1" + "github.com/trisacrypto/directory/pkg/gds/secrets" ) // AddCollaborator creates a new collaborator with the email address in the request. @@ -85,12 +86,15 @@ func (s *Server) AddCollaborator(c *gin.Context) { } // Create an unverified user in Auth0 - // Note: If the user has already registered with a social IDP then they will need to link accounts - var verified bool - connection := "Username-Password-Authentication" - user.Email = &collaborator.Email - user.Connection = &connection - user.EmailVerified = &verified + // Note: If the user has already registered with another IDP then they will need to link accounts + var verifyEmail bool + password := secrets.CreateToken(32) + user = &management.User{ + Email: &collaborator.Email, + Password: &password, + Connection: &s.conf.Auth0.ConnectionName, + VerifyEmail: &verifyEmail, + } if err = s.auth0.User.Create(user); err != nil { log.Error().Err(err).Str("email", collaborator.Email).Msg("error creating user in Auth0") c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) @@ -99,29 +103,9 @@ func (s *Server) AddCollaborator(c *gin.Context) { } // Generate the user verification link containing a redirect URL with the org ID - redirectURL := fmt.Sprintf("%s?orgid=%s", s.conf.Auth0.RedirectURL, org.Id) - expiration := int((time.Hour * 24 * 7).Seconds()) // 7 days - ticket := &management.Ticket{ - UserID: user.ID, - ResultURL: &redirectURL, - TTLSec: &expiration, - MarkEmailAsVerified: user.EmailVerified, - } - if err = s.auth0.Ticket.ChangePassword(ticket); err != nil { - log.Error().Err(err).Str("email", collaborator.Email).Msg("error creating password change ticket in Auth0") - c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) - return - } - if ticket.Ticket == nil { - log.Error().Err(err).Str("user_id", *user.ID).Msg("could not retrieve password change ticket URL from Auth0") - c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) - return - } - - // Parse the ticket string into a URL var inviteURL *url.URL - if inviteURL, err = url.Parse(*ticket.Ticket); err != nil { - log.Error().Err(err).Str("ticket", *ticket.Ticket).Msg("could not parse password change ticket URL") + if inviteURL, err = s.GetAuth0UserInviteURL(user, org); err != nil { + log.Error().Err(err).Str("email", collaborator.Email).Str("org_id", org.Id).Msg("error generating user invite URL") c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add collaborator")) return } @@ -364,6 +348,50 @@ func (s *Server) LoadCollaboratorDetails(collab *models.Collaborator) (err error return nil } +// GetAuth0UserInviteURL generates a user ticket in Auth0 to invite the user to the +// organization and returns the ticket URL. Depending on the current state of the user, +// this will either be a password change ticket or an email verification ticket. The +// caller is responsible for sending the user the ticket URL, which is usually done by +// including it in the user invite email. +func (s *Server) GetAuth0UserInviteURL(user *management.User, org *models.Organization) (inviteURL *url.URL, err error) { + // Ticket should include the redirect to the auth callback and should expire in 7 days + redirectURL := fmt.Sprintf("%s?orgid=%s", s.conf.Auth0.RedirectURL, org.Id) + expiration := int((time.Hour * 24 * 7).Seconds()) + ticket := &management.Ticket{ + UserID: user.ID, + ResultURL: &redirectURL, + TTLSec: &expiration, + } + + if user.EmailVerified != nil && *user.EmailVerified { + // If the user is already verified they don't need to set their password. This + // generates a link which directs the user to the email verification page, + // although it will short circuit to the redirect URL if the user is already + // verified. + // TODO: This will not work for users whose primary connection is an external + // IDP such as Google and will instead return an error. + if err = s.auth0.Ticket.VerifyEmail(ticket); err != nil { + return nil, err + } + } else { + // This generates a link which directs the user to the password reset page, + // allowing them to properly set their password so they can log in. + if err = s.auth0.Ticket.ChangePassword(ticket); err != nil { + return nil, err + } + } + if ticket.Ticket == nil || *ticket.Ticket == "" { + return nil, errors.New("URL is missing from Auth0 ticket") + } + + // Parse the ticket string into a URL + if inviteURL, err = url.Parse(*ticket.Ticket); err != nil { + return nil, err + } + + return inviteURL, nil +} + // InsortCollaborator is a helper function to insert a collaborator into a sorted slice // using a custom sort function. func InsortCollaborator(collabs []*models.Collaborator, value *models.Collaborator, f func(a, b *models.Collaborator) bool) []*models.Collaborator { diff --git a/pkg/bff/config/config.go b/pkg/bff/config/config.go index 9bbaa8a8a..90def1d1e 100644 --- a/pkg/bff/config/config.go +++ b/pkg/bff/config/config.go @@ -45,14 +45,15 @@ type Config struct { // AuthConfig handles Auth0 configuration and authentication type AuthConfig struct { - Domain string `split_words:"true" required:"true"` - Issuer string `split_words:"true" required:"false"` // Set to the custom domain if enabled in Auth0 (ensure trailing slash is set if required!) - Audience string `split_words:"true" required:"true"` - RedirectURL string `split_words:"true" required:"true"` - ProviderCache time.Duration `split_words:"true" default:"5m"` - ClientID string `split_words:"true"` - ClientSecret string `split_words:"true"` - Testing bool `split_words:"true" default:"false"` // If true a mock authenticator is used for testing + Domain string `split_words:"true" required:"true"` + Issuer string `split_words:"true" required:"false"` // Set to the custom domain if enabled in Auth0 (ensure trailing slash is set if required!) + Audience string `split_words:"true" required:"true"` + ConnectionName string `split_words:"true" required:"true"` + RedirectURL string `split_words:"true" required:"true"` + ProviderCache time.Duration `split_words:"true" default:"5m"` + ClientID string `split_words:"true"` + ClientSecret string `split_words:"true"` + Testing bool `split_words:"true" default:"false"` // If true a mock authenticator is used for testing } // NetworkConfig contains sub configurations for connecting to specific GDS and members @@ -167,6 +168,10 @@ func (c MembersConfig) Validate() error { } func (c AuthConfig) Validate() error { + if c.ConnectionName == "" { + return errors.New("invalid configuration: auth0 connection name is required") + } + if _, err := c.IssuerURL(); err != nil { return err } diff --git a/pkg/bff/config/config_test.go b/pkg/bff/config/config_test.go index dde036619..7f19ec94f 100644 --- a/pkg/bff/config/config_test.go +++ b/pkg/bff/config/config_test.go @@ -20,6 +20,7 @@ var testEnv = map[string]string{ "GDS_BFF_COOKIE_DOMAIN": "vaspdirectory.net", "GDS_BFF_AUTH0_DOMAIN": "example.auth0.com", "GDS_BFF_AUTH0_ISSUER": "https://auth.example.com", + "GDS_BFF_AUTH0_CONNECTION_NAME": "Username-Password-Authentication", "GDS_BFF_AUTH0_REDIRECT_URL": "https://vaspdirectory.net/auth/callback", "GDS_BFF_AUTH0_AUDIENCE": "https://vaspdirectory.net", "GDS_BFF_AUTH0_PROVIDER_CACHE": "10m", @@ -95,6 +96,7 @@ func TestConfig(t *testing.T) { require.Equal(t, testEnv["GDS_BFF_AUTH0_DOMAIN"], conf.Auth0.Domain) require.Equal(t, testEnv["GDS_BFF_AUTH0_ISSUER"], conf.Auth0.Issuer) require.Equal(t, testEnv["GDS_BFF_AUTH0_AUDIENCE"], conf.Auth0.Audience) + require.Equal(t, testEnv["GDS_BFF_AUTH0_CONNECTION_NAME"], conf.Auth0.ConnectionName) require.Equal(t, testEnv["GDS_BFF_AUTH0_REDIRECT_URL"], conf.Auth0.RedirectURL) require.Equal(t, testEnv["GDS_BFF_AUTH0_CLIENT_ID"], conf.Auth0.ClientID) require.Equal(t, testEnv["GDS_BFF_AUTH0_CLIENT_SECRET"], conf.Auth0.ClientSecret) @@ -145,6 +147,7 @@ func TestRequiredConfig(t *testing.T) { required := []string{ "GDS_BFF_AUTH0_DOMAIN", "GDS_BFF_AUTH0_AUDIENCE", + "GDS_BFF_AUTH0_CONNECTION_NAME", "GDS_BFF_AUTH0_REDIRECT_URL", "GDS_BFF_AUTH0_CLIENT_ID", "GDS_BFF_AUTH0_CLIENT_SECRET", @@ -202,18 +205,24 @@ func TestRequiredConfig(t *testing.T) { func TestAuthConfig(t *testing.T) { conf := config.AuthConfig{ - Domain: "example.auth0.com", - RedirectURL: "https://vaspdirectory.net/auth/callback", - Audience: "https://vaspdirectory.net", - ProviderCache: 0, - Testing: true, + Domain: "example.auth0.com", + RedirectURL: "https://vaspdirectory.net/auth/callback", + Audience: "https://vaspdirectory.net", + ConnectionName: "Username-Password-Authentication", + ProviderCache: 0, + Testing: true, } // Ensure that a provider cache is required require.EqualError(t, conf.Validate(), "invalid configuration: auth0 provider cache duration should be longer than 0") - // Ensure that client ID and secret are not required when testing + // Ensure that a connection name is required conf.ProviderCache = 5 * time.Minute + conf.ConnectionName = "" + require.EqualError(t, conf.Validate(), "invalid configuration: auth0 connection name is required") + + // Ensure that client ID and secret are not required when testing + conf.ConnectionName = "Username-Password-Authentication" require.NoError(t, conf.Validate(), "could not validate auth config") // Ensure that client Id and secret are required when not testing diff --git a/pkg/bff/status_test.go b/pkg/bff/status_test.go index 4558b2790..325c7d188 100644 --- a/pkg/bff/status_test.go +++ b/pkg/bff/status_test.go @@ -183,11 +183,12 @@ func (s *bffTestSuite) TestMaintenanceMode() { AllowOrigins: []string{"http://localhost"}, CookieDomain: "localhost", Auth0: config.AuthConfig{ - Domain: "auth.localhost", - Audience: "http://localhost", - RedirectURL: "http://localhost/auth/callback", - ProviderCache: 5 * time.Minute, - Testing: true, + Domain: "auth.localhost", + Audience: "http://localhost", + ConnectionName: "Username-Password-Authentication", + RedirectURL: "http://localhost/auth/callback", + ProviderCache: 5 * time.Minute, + Testing: true, }, TestNet: config.NetworkConfig{ Directory: config.DirectoryConfig{ From 56327ad743fbed5a93e7418325dc9736ecc4a218 Mon Sep 17 00:00:00 2001 From: Patrick Deziel Date: Thu, 17 Nov 2022 11:56:15 -0600 Subject: [PATCH 3/5] fix lint error --- pkg/bff/auth/authtest/server.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/bff/auth/authtest/server.go b/pkg/bff/auth/authtest/server.go index 653a9de73..bfc4d7aa4 100644 --- a/pkg/bff/auth/authtest/server.go +++ b/pkg/bff/auth/authtest/server.go @@ -20,7 +20,6 @@ import ( "crypto/rsa" "encoding/json" "errors" - "fmt" "net/http" "net/http/httptest" "net/url" @@ -318,7 +317,7 @@ func (s *Server) GenerateTicket(w http.ResponseWriter, r *http.Request) { return } - url := fmt.Sprintf("https://example.com/tickets/1234") + url := "https://example.com/tickets/1234" ticket.Ticket = &url w.Header().Add("Content-Type", "application/json") From ec74ca7755b1c1e3cee282542a80951846df8cc6 Mon Sep 17 00:00:00 2001 From: Patrick Deziel Date: Mon, 21 Nov 2022 17:07:42 -0600 Subject: [PATCH 4/5] fix file formatting --- pkg/bff/errors.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/bff/errors.go b/pkg/bff/errors.go index b3dca5dde..feb052f49 100644 --- a/pkg/bff/errors.go +++ b/pkg/bff/errors.go @@ -9,7 +9,7 @@ var ( ErrEmptyAnnouncement = errors.New("cannot post a zero-valued announcement") ErrUnboundedRecent = errors.New("cannot specify zero-valued not before otherwise announcements fetch is unbounded") ErrInvalidUserRole = errors.New("invalid user role specified") - ErrUserEmailNotFound = errors.New("could not find user by email address") - ErrMultipleEmailUsers = errors.New("multiple users found by email address") + ErrUserEmailNotFound = errors.New("could not find user by email address") + ErrMultipleEmailUsers = errors.New("multiple users found by email address") ErrDomainAlreadyExists = errors.New("the specified domain already exists") ) From 9c4b198ddafdcfeb38ea7825853427e4b37d9345 Mon Sep 17 00:00:00 2001 From: Patrick Deziel Date: Mon, 21 Nov 2022 17:42:42 -0600 Subject: [PATCH 5/5] update redirect URL in docker compose --- containers/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/docker-compose.yaml b/containers/docker-compose.yaml index 3e4280a72..dccedd050 100644 --- a/containers/docker-compose.yaml +++ b/containers/docker-compose.yaml @@ -172,7 +172,7 @@ services: - GDS_BFF_AUTH0_TESTING - GDS_BFF_AUTH0_DOMAIN=dev-bu-hbv3o.us.auth0.com - GDS_BFF_AUTH0_CONNECTION_NAME=Username-Password-Authentication - - GDS_BFF_AUTH0_REDIRECT_URL=http://vaspdirectory.dev/auth/callback + - GDS_BFF_AUTH0_REDIRECT_URL=http://localhost:3000/auth/login - GDS_BFF_AUTH0_ISSUER=https://auth.vaspdirectory.dev/ - GDS_BFF_AUTH0_AUDIENCE=https://bff.vaspdirectory.dev - GDS_BFF_AUTH0_CLIENT_ID