From 5793970e5dbe3409fbebb8ddb23f46891ab98ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Franke?= Date: Wed, 29 Mar 2023 13:37:03 +0200 Subject: [PATCH] Add keycloak package. This PR adds the keycloak package, which contains the following: * More genericised functions for the invitations backend. * User query functions. * PII query functions. --- ocis-pkg/keycloak/client.go | 234 ++++++++++++++++++ ocis-pkg/keycloak/gocloak.go | 19 ++ .../pkg/backends/keycloak/backend.go | 116 +++------ 3 files changed, 290 insertions(+), 79 deletions(-) create mode 100644 ocis-pkg/keycloak/client.go create mode 100644 ocis-pkg/keycloak/gocloak.go diff --git a/ocis-pkg/keycloak/client.go b/ocis-pkg/keycloak/client.go new file mode 100644 index 000000000..1544cddc2 --- /dev/null +++ b/ocis-pkg/keycloak/client.go @@ -0,0 +1,234 @@ +// Package keycloak is a package for keycloak utility functions. +package keycloak + +import ( + "context" + "crypto/tls" + "fmt" + + "github.com/Nerzal/gocloak/v13" + libregraph "github.com/owncloud/libre-graph-api-go" +) + +// Some attribute constants. +// TODO: Make these configurable in the future. +const ( + idAttr = "OWNCLOUD_ID" + userTypeAttr = "OWNCLOUD_USER_TYPE" +) + +// Client represents a keycloak client +type Client struct { + keycloak GoCloak + clientID string + clientSecret string + realm string + baseURL string +} + +// PIIReport is a structure of all the PersonalIdentifiableInformation contained in keycloak. +type PIIReport struct { + UserData *libregraph.User + Credentials []*gocloak.CredentialRepresentation +} + +// UserAction defines a type for user actions +type UserAction int8 + +// An incomplete list of UserActions +const ( + // UserActionUpdatePassword sets it that the user needs to change their password. + UserActionUpdatePassword UserAction = iota + // UserActionVerifyEmail sets it that the user needs to verify their email address. + UserActionVerifyEmail +) + +// A lookup table to translate user actions to their string equivalents +var userActionsToString = map[UserAction]string{ + UserActionUpdatePassword: "UPDATE_PASSWORD", + UserActionVerifyEmail: "VERIFY_EMAIL", +} + +// New instantiates a new keycloak.Backend with a default gocloak client. +func New( + baseURL, clientID, clientSecret, realm string, + insecureSkipVerify bool, +) *Client { + gc := gocloak.NewClient(baseURL) + restyClient := gc.RestyClient() + restyClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecureSkipVerify}) //nolint:gosec + return NewWithClient(gc, baseURL, clientID, clientSecret, realm) +} + +// NewWithClient instantiates a new keycloak.Backend with a custom +func NewWithClient( + gocloakClient GoCloak, + baseURL, clientID, clientSecret, realm string, +) *Client { + return &Client{ + keycloak: gocloakClient, + baseURL: baseURL, + clientID: clientID, + clientSecret: clientSecret, + realm: realm, + } +} + +// CreateUser creates a user from a libregraph user and returns its *keycloak* ID. +// TODO: For now we only call this from the invitation service where all the attributes are set correctly. +// +// For more wider use, do some sanity checking on the user instance. +func (c *Client) CreateUser(ctx context.Context, realm string, user *libregraph.User, userActions []UserAction) (string, error) { + token, err := c.getToken(ctx) + if err != nil { + return "", err + } + + req := gocloak.User{ + Email: user.Mail, + Enabled: user.AccountEnabled, + Username: user.OnPremisesSamAccountName, + FirstName: user.GivenName, + LastName: user.Surname, + Attributes: &map[string][]string{ + idAttr: {user.GetId()}, + userTypeAttr: {user.GetUserType()}, + }, + RequiredActions: convertUserActions(userActions), + } + return c.keycloak.CreateUser(ctx, token.AccessToken, realm, req) +} + +// SendActionsMail sends a mail to the user with userID instructing them to do the actions defined in userActions. +func (c *Client) SendActionsMail(ctx context.Context, realm, userID string, userActions []UserAction) error { + token, err := c.getToken(ctx) + if err != nil { + return err + } + params := gocloak.ExecuteActionsEmail{ + UserID: &userID, + Actions: convertUserActions(userActions), + } + + return c.keycloak.ExecuteActionsEmail(ctx, token.AccessToken, realm, params) +} + +// GetUserByEmail looks up a user by email. +func (c *Client) GetUserByEmail(ctx context.Context, realm, mail string) (*libregraph.User, error) { + token, err := c.getToken(ctx) + if err != nil { + return nil, err + } + + users, err := c.keycloak.GetUsers(ctx, token.AccessToken, realm, gocloak.GetUsersParams{ + Email: &mail, + }) + if err != nil { + return nil, err + } + + if len(users) == 0 { + return nil, fmt.Errorf("no users found with mail address %s", mail) + } + + if len(users) > 1 { + return nil, fmt.Errorf("%d users found with mail address %s, expected 1", len(users), mail) + } + + return c.keycloakUserToLibregraph(users[0]), nil +} + +// GetPIIReport returns a structure with all the PII for the user. +func (c *Client) GetPIIReport(ctx context.Context, realm string, user *libregraph.User) (*PIIReport, error) { + u, err := c.GetUserByEmail(ctx, realm, *user.Mail) + if err != nil { + return nil, err + } + + token, err := c.getToken(ctx) + if err != nil { + return nil, err + } + + keycloakID, err := c.getKeyCloakID(u) + if err != nil { + return nil, err + } + + creds, err := c.keycloak.GetCredentials(ctx, token.AccessToken, realm, keycloakID) + if err != nil { + return nil, err + } + + return &PIIReport{ + UserData: u, + Credentials: creds, + }, nil +} + +// getToken gets a fresh token for the request. +// TODO: set a token on the struct and check if it's still valid before requesting a new one. +func (c *Client) getToken(ctx context.Context) (*gocloak.JWT, error) { + token, err := c.keycloak.LoginClient(ctx, c.clientID, c.clientSecret, c.realm) + if err != nil { + return nil, fmt.Errorf("failed to get token: %w", err) + } + + rRes, err := c.keycloak.RetrospectToken(ctx, token.AccessToken, c.clientID, c.clientSecret, c.realm) + if err != nil { + return nil, fmt.Errorf("failed to retrospect token: %w", err) + } + + if !*rRes.Active { + return nil, fmt.Errorf("token is not active") + } + + return token, nil +} + +func (c *Client) keycloakUserToLibregraph(u *gocloak.User) *libregraph.User { + attrs := *u.Attributes + ldapID := "" + ldapIDs, ok := attrs[idAttr] + if ok { + ldapID = ldapIDs[0] + } + + var userType *string + userTypes, ok := attrs[userTypeAttr] + if ok { + userType = &userTypes[0] + } + + return &libregraph.User{ + Id: &ldapID, + Mail: u.Email, + GivenName: u.FirstName, + Surname: u.LastName, + AccountEnabled: u.Enabled, + UserType: userType, + Identities: []libregraph.ObjectIdentity{ + { + Issuer: &c.baseURL, + IssuerAssignedId: u.ID, + }, + }, + } +} + +func (c *Client) getKeyCloakID(u *libregraph.User) (string, error) { + for _, i := range u.Identities { + if *i.Issuer == c.baseURL { + return *i.IssuerAssignedId, nil + } + } + return "", fmt.Errorf("could not find identity for issuer: %s", c.baseURL) +} + +func convertUserActions(userActions []UserAction) *[]string { + stringActions := make([]string, len(userActions)) + for i, a := range userActions { + stringActions[i] = userActionsToString[a] + } + return &stringActions +} diff --git a/ocis-pkg/keycloak/gocloak.go b/ocis-pkg/keycloak/gocloak.go new file mode 100644 index 000000000..c4a4de0cd --- /dev/null +++ b/ocis-pkg/keycloak/gocloak.go @@ -0,0 +1,19 @@ +package keycloak + +import ( + "context" + + "github.com/Nerzal/gocloak/v13" +) + +// GoCloak represents the parts of gocloak.GoCloak that we use, mainly here for mockery. +// +//go:generate mockery --name=GoCloak +type GoCloak interface { + CreateUser(ctx context.Context, token, realm string, user gocloak.User) (string, error) + GetUsers(ctx context.Context, token, realm string, params gocloak.GetUsersParams) ([]*gocloak.User, error) + ExecuteActionsEmail(ctx context.Context, token, realm string, params gocloak.ExecuteActionsEmail) error + LoginClient(ctx context.Context, clientID, clientSecret, realm string) (*gocloak.JWT, error) + RetrospectToken(ctx context.Context, accessToken, clientID, clientSecret, realm string) (*gocloak.IntroSpectTokenResult, error) + GetCredentials(ctx context.Context, accessToken, realm, userID string) ([]*gocloak.CredentialRepresentation, error) +} diff --git a/services/invitations/pkg/backends/keycloak/backend.go b/services/invitations/pkg/backends/keycloak/backend.go index fa42f2c51..305036363 100644 --- a/services/invitations/pkg/backends/keycloak/backend.go +++ b/services/invitations/pkg/backends/keycloak/backend.go @@ -3,31 +3,28 @@ package keycloak import ( "context" - "crypto/tls" - "fmt" - "github.com/Nerzal/gocloak/v13" "github.com/google/uuid" + libregraph "github.com/owncloud/libre-graph-api-go" + "github.com/owncloud/ocis/v2/ocis-pkg/keycloak" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" ) const ( - idAttr = "OWNCLOUD_ID" - userTypeAttr = "OWNCLOUD_USER_TYPE" - userTypeVal = "Guest" + userType = "Guest" ) -var userRequiredActions = []string{"UPDATE_PASSWORD", "VERIFY_EMAIL"} +var userRequiredActions = []keycloak.UserAction{ + keycloak.UserActionUpdatePassword, + keycloak.UserActionVerifyEmail, +} // Backend represents the keycloak backend. type Backend struct { - logger log.Logger - client GoCloak - clientID string - clientSecret string - clientRealm string - userRealm string + logger log.Logger + client *keycloak.Client + userRealm string } // New instantiates a new keycloak.Backend with a default gocloak client. @@ -36,62 +33,50 @@ func New( baseURL, clientID, clientSecret, clientRealm, userRealm string, insecureSkipVerify bool, ) *Backend { - client := gocloak.NewClient(baseURL) - restyClient := client.RestyClient() - restyClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecureSkipVerify}) //nolint:gosec - return NewWithClient(logger, client, clientID, clientSecret, clientRealm, userRealm) + logger = log.Logger{ + Logger: logger.With(). + Str("invitationBackend", "keycloak"). + Str("clientID", clientID). + Str("clientRealm", clientRealm). + Str("userRealm", userRealm). + Logger(), + } + client := keycloak.New(baseURL, clientID, clientSecret, clientRealm, insecureSkipVerify) + return NewWithClient(logger, client, userRealm) } -// NewWithClient creates a new backend with the supplied GoCloak client. +// NewWithClient creates a new backend with the supplied keycloak client. func NewWithClient( logger log.Logger, - client GoCloak, - clientID, clientSecret, clientRealm, userRealm string, + client *keycloak.Client, + userRealm string, ) *Backend { return &Backend{ - logger: log.Logger{ - Logger: logger.With(). - Str("invitationBackend", "keycloak"). - Str("clientID", clientID). - Str("clientRealm", clientRealm). - Str("userRealm", userRealm). - Logger(), - }, - client: client, - clientID: clientID, - clientSecret: clientSecret, - clientRealm: clientRealm, - userRealm: userRealm, + logger: logger, + client: client, + userRealm: userRealm, } } // CreateUser creates a user in the keycloak backend. func (b Backend) CreateUser(ctx context.Context, invitation *invitations.Invitation) (string, error) { - token, err := b.getToken(ctx) - if err != nil { - return "", err - } u := uuid.New() b.logger.Info(). - Str(idAttr, u.String()). Str("email", invitation.InvitedUserEmailAddress). Msg("Creating new user") - user := gocloak.User{ - Email: &invitation.InvitedUserEmailAddress, - Enabled: gocloak.BoolP(true), - Username: &invitation.InvitedUserEmailAddress, - Attributes: &map[string][]string{ - idAttr: {u.String()}, - userTypeAttr: {userTypeVal}, - }, - RequiredActions: &userRequiredActions, + user := &libregraph.User{ + Mail: &invitation.InvitedUserEmailAddress, + AccountEnabled: boolP(true), + OnPremisesSamAccountName: &invitation.InvitedUserEmailAddress, + Id: stringP(u.String()), + UserType: stringP(userType), } - id, err := b.client.CreateUser(ctx, token.AccessToken, b.userRealm, user) + id, err := b.client.CreateUser(ctx, b.userRealm, user, userRequiredActions) if err != nil { b.logger.Error(). - Str(idAttr, u.String()). + Str("userID", u.String()). Str("email", invitation.InvitedUserEmailAddress). Err(err). Msg("Failed to create user") @@ -106,35 +91,8 @@ func (b Backend) CanSendMail() bool { return true } // SendMail sends a mail to the user with details on how to reedeem the invitation. func (b Backend) SendMail(ctx context.Context, id string) error { - token, err := b.getToken(ctx) - if err != nil { - return err - } - params := gocloak.ExecuteActionsEmail{ - UserID: &id, - Actions: &userRequiredActions, - } - return b.client.ExecuteActionsEmail(ctx, token.AccessToken, b.userRealm, params) + return b.client.SendActionsMail(ctx, b.userRealm, id, userRequiredActions) } -func (b Backend) getToken(ctx context.Context) (*gocloak.JWT, error) { - b.logger.Debug().Msg("Logging into keycloak") - token, err := b.client.LoginClient(ctx, b.clientID, b.clientSecret, b.clientRealm) - if err != nil { - b.logger.Error().Err(err).Msg("failed to get token") - return nil, fmt.Errorf("failed to get token: %w", err) - } - - rRes, err := b.client.RetrospectToken(ctx, token.AccessToken, b.clientID, b.clientSecret, b.clientRealm) - if err != nil { - b.logger.Error().Err(err).Msg("failed to introspect token") - return nil, fmt.Errorf("failed to retrospect token: %w", err) - } - - if !*rRes.Active { - b.logger.Error().Msg("token not active") - return nil, fmt.Errorf("token is not active") - } - - return token, nil -} +func boolP(b bool) *bool { return &b } +func stringP(s string) *string { return &s }