Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auth0 user invite tickets #895

Merged
merged 10 commits into from
Nov 21, 2022
Merged
2 changes: 2 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions containers/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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://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
Expand Down
90 changes: 72 additions & 18 deletions pkg/bff/auth/authtest/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,20 @@ import (
)

const (
KeyID = "StyqeY8Kl4Eam28KsUs"
ClientID = "a5laOSr0NOX1L53yBaNtumKOoExFxptc"
ClientSecret = "me4JZSvBvPSnBaM0h0AoXgXPn1VBiBMz0bL7E/sV1isndP9lZ5ptm5NWA9IkKwEb"
Audience = "http://localhost"
Name = "Leopold Wentzel"
Email = "[email protected]"
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 = "[email protected]"
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 (
Expand Down Expand Up @@ -116,6 +118,9 @@ 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.mux.HandleFunc("/api/v2/tickets/email-verification", s.GenerateTicket)

s.srv = httptest.NewTLSServer(s.mux)
s.URL, _ = url.Parse(s.srv.URL)
Expand All @@ -125,12 +130,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,
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,
}
}

Expand Down Expand Up @@ -228,6 +235,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:
Expand All @@ -246,6 +255,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:
Expand All @@ -259,6 +284,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
Expand All @@ -270,6 +309,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 := "https://example.com/tickets/1234"
ticket.Ticket = &url

w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(ticket)
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added these methods so the Auth0 management API doesn't error on us during tests.

type RoleParams struct {
Roles []string `json:"roles"`
}
Expand Down
96 changes: 94 additions & 2 deletions pkg/bff/collaborators.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"time"

Expand All @@ -13,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.
Expand All @@ -23,6 +25,7 @@ func (s *Server) AddCollaborator(c *gin.Context) {
var (
err error
collaborator *models.Collaborator
inviter *management.User
org *models.Organization
)

Expand All @@ -32,6 +35,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 {
Expand Down Expand Up @@ -66,8 +76,46 @@ 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 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"))
return
}
}

// Generate the user verification link containing a redirect URL with the org ID
var inviteURL *url.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
}

// 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 {
Expand Down Expand Up @@ -300,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,
DanielSollis marked this conversation as resolved.
Show resolved Hide resolved
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 {
Expand Down
5 changes: 5 additions & 0 deletions pkg/bff/collaborators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -79,6 +81,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")
Expand Down
41 changes: 34 additions & 7 deletions pkg/bff/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +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"`
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
Expand Down Expand Up @@ -166,10 +168,18 @@ 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
}

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")
}
Expand Down Expand Up @@ -213,6 +223,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)
}
Expand Down
Loading