Use keycloak for invitations backend.

As keycloak already supports everything needed for the required
invitation flow, it's ideal to use as the first backend to create users
and to send them invitation mails.

This PR implements that as the first and (for now) only backend.
This commit is contained in:
Daniël Franke
2023-03-20 12:53:14 +01:00
parent fc5e4ea7d1
commit f244869e91
8 changed files with 218 additions and 118 deletions

5
go.mod
View File

@@ -113,6 +113,7 @@ require (
github.com/Masterminds/sprig v2.22.0+incompatible // indirect
github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/Nerzal/gocloak/v13 v13.1.0 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20220930113650-c6815a8c17ad // indirect
github.com/RoaringBitmap/roaring v0.9.4 // indirect
github.com/acomagu/bufpipe v1.0.3 // indirect
@@ -185,6 +186,8 @@ require (
github.com/go-logfmt/logfmt v0.5.1 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-resty/resty/v2 v2.7.0 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
@@ -260,6 +263,7 @@ require (
github.com/nats-io/nuid v1.0.1 // indirect
github.com/nxadm/tail v1.4.8 // indirect
github.com/opencontainers/runtime-spec v1.0.2 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -276,6 +280,7 @@ require (
github.com/russellhaering/goxmldsig v1.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sciencemesh/meshdirectory-web v1.0.4 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/sethvargo/go-password v0.2.0 // indirect
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect

8
go.sum
View File

@@ -435,6 +435,8 @@ github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugX
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
github.com/Nerzal/gocloak/v13 v13.1.0 h1:ret4pZTIsSQGZHURDMJ4jXnUmHyEoRykBqDTsAKoj8c=
github.com/Nerzal/gocloak/v13 v13.1.0/go.mod h1:rRBtEdh5N0+JlZZEsrfZcB2sRMZWbgSxI2EIv9jpJp4=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
@@ -823,6 +825,8 @@ github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8=
github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY=
github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -1362,6 +1366,7 @@ github.com/open-policy-agent/opa v0.50.0/go.mod h1:9jKfDk0L5b9rnhH4M0nq10cGHbYOx
github.com/opencontainers/runtime-spec v1.0.2 h1:UfAcuLBJB9Coz72x1hgl8O5RVzTdNiaglX6v2DM6FI0=
github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/oracle/oci-go-sdk v24.3.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888=
@@ -1489,6 +1494,8 @@ github.com/sciencemesh/meshdirectory-web v1.0.4 h1:1YSctF6PAXhoHUYCaeRTj7rHaF7b3
github.com/sciencemesh/meshdirectory-web v1.0.4/go.mod h1:fJSThTS3xf+sTdL0iXQoaQJssLI7tn7DetHMHUl4SRk=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
@@ -1812,6 +1819,7 @@ golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=

View File

@@ -0,0 +1,149 @@
// Package keycloak offers an invitation backend for the invitation service.
// TODO: Maybe move this outside of the invitation service and make it more generic?
package keycloak
import (
"context"
"crypto/tls"
"fmt"
"strings"
"github.com/Nerzal/gocloak/v13"
"github.com/google/uuid"
"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"
)
var userRequiredActions = []string{"UPDATE_PASSWORD", "VERIFY_EMAIL"}
// Backend represents the keycloak backend.
type Backend struct {
logger log.Logger
client *gocloak.GoCloak
clientID string
clientSecret string
clientRealm string
userRealm string
}
// New returns a new keycloak.Backend, with all the config options set.
func New(
logger log.Logger,
baseURL, clientID, clientSecret, clientRealm, userRealm string,
insecureSkipVerify bool,
) *Backend {
client := gocloak.NewClient(baseURL)
restyClient := client.RestyClient()
restyClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecureSkipVerify})
return &Backend{
logger: log.Logger{
logger.With().
Str("invitationBackend", "keycloak").
Str("baseURL", baseURL).
Str("clientID", clientID).
Str("clientRealm", clientRealm).
Str("userRealm", userRealm).
Logger(),
},
client: client,
clientID: clientID,
clientSecret: clientSecret,
clientRealm: clientRealm,
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()
firstName, lastName := splitDisplayName(invitation.InvitedUserDisplayName)
b.logger.Info().
Str(idAttr, u.String()).
Str("email", invitation.InvitedUserEmailAddress).
Msg("Creating new user")
user := gocloak.User{
FirstName: &firstName,
LastName: &lastName,
Email: &invitation.InvitedUserEmailAddress,
Enabled: gocloak.BoolP(true),
Username: &invitation.InvitedUserEmailAddress,
Attributes: &map[string][]string{
idAttr: {u.String()},
userTypeAttr: {userTypeVal},
},
RequiredActions: &userRequiredActions,
}
id, err := b.client.CreateUser(ctx, token.AccessToken, b.userRealm, user)
if err != nil {
b.logger.Error().
Str(idAttr, u.String()).
Str("email", invitation.InvitedUserEmailAddress).
Err(err).
Msg("Failed to create user")
return "", err
}
return id, nil
}
// CanSendMail returns true because keycloak does allow to send mail.
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)
}
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
}
// Quick and dirty way to split the last name off from the first name(s), imperfect, because
// every culture has a different conception of names.
func splitDisplayName(displayName string) (string, string) {
parts := strings.Split(displayName, " ")
if len(parts) <= 1 {
return parts[0], ""
}
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
}

View File

@@ -18,18 +18,18 @@ type Config struct {
HTTP HTTP `yaml:"http"`
Endpoint Endpoint `yaml:"enpoint"`
Keycloak Keycloak `yaml:"keycloak"`
TokenManager *TokenManager `yaml:"token_manager"`
Context context.Context `yaml:"-"`
}
// Endpoint to use
type Endpoint struct {
URL string `yaml:"url" env:"INVITATIONS_PROVISIONING_URL" desc:"The endpoint provisioning requests are sent to."`
Method string `yaml:"method" env:"INVITATIONS_PROVISIONING_METHOD" desc:"The method to use when making provisioning requests."`
BodyTemplate string `yaml:"body_template" env:"INVITATIONS_PROVISIONING_BODY_TEMPLATE" desc:"The template to use as body of a provisioning request."`
Authorization string `yaml:"authorization" env:"INVITATIONS_PROVISIONING_AUTH" desc:"The authorization to use. Can be 'token' to reuse the access token or 'bearer' to send a static api token."`
Token string `yaml:"authorization" env:"INVITATIONS_PROVISIONING_AUTH" desc:"The bearer token to send in provisioning requests."`
// Keycloak configuration
type Keycloak struct {
BasePath string `yaml:"base_path" env:"INVITATIONS_KEYCLOAK_BASE_PATH" desc:"The URL to keycloak."`
ClientID string `yaml:"client_id" env:"INVITATIONS_KEYCLOAK_CLIENT_ID" desc:"The client id to authenticate with keycloak."`
ClientSecret string `yaml:"client_secret" env:"INVITATIONS_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication."`
ClientRealm string `yaml:"client_realm" env:"INVITATIONS_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in."`
UserRealm string `yaml:"user_realm" env:"INVITATIONS_KEYCLOAK_USER_REALM" desc:"The realm the users are in."`
InsecureSkipVerify bool `yaml:"insecure_skip_verify" env:"INVITATIONS_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Skip the check of the TLS certificate."`
}

View File

@@ -32,16 +32,12 @@ func DefaultConfig() *config.Config {
Service: config.Service{
Name: "invitations",
},
Endpoint: config.Endpoint{
URL: "{{.OCIS_URL}}/graph/v1.0/users",
Method: "POST",
BodyTemplate: `{
"inviteRedirectUrl": "{{.redirectUrl}}",
"invitedUserEmailAddress": "{{.mail}}",
"invitedUserDisplayName": "{{.displayName}}",
"sendInvitationMessage": true
}`,
Authorization: "token", // reuse existing token
Keycloak: config.Keycloak{
BasePath: "https://keycloak.example.org/",
ClientID: "invitations-service",
ClientSecret: "fake-secret",
ClientRealm: "someRealm",
UserRealm: "someRealm",
},
}
}

View File

@@ -91,7 +91,7 @@ func InvitationHandler(service svc.Service) func(w http.ResponseWriter, r *http.
i := &invitations.Invitation{}
err := json.NewDecoder(r.Body).Decode(i)
if err != nil {
//logger.Debug().Err(err).Interface("body", r.Body).Msg("could not invite user: invalid request body")
// logger.Debug().Err(err).Interface("body", r.Body).Msg("could not invite user: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error()))
return
}
@@ -103,7 +103,7 @@ func InvitationHandler(service svc.Service) func(w http.ResponseWriter, r *http.
return
}
//w.Header().Set("Content-type", "application/json")
// w.Header().Set("Content-type", "application/json")
render.Status(r, http.StatusCreated)
render.JSON(w, r, res)
}

View File

@@ -6,4 +6,5 @@ var (
ErrNotFound = errors.New("query target not found")
ErrBadRequest = errors.New("bad request")
ErrMissingEmail = errors.New("missing email address")
ErrBackend = errors.New("backend error")
)

View File

@@ -1,17 +1,11 @@
package service
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"strings"
"text/template"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/invitations/pkg/backends/keycloak"
"github.com/owncloud/ocis/v2/services/invitations/pkg/config"
"github.com/owncloud/ocis/v2/services/invitations/pkg/invitations"
)
@@ -40,33 +34,46 @@ type Service interface {
Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error)
}
// Backend defines the behaviour of a user backend.
type Backend interface {
// CreateUser creates a user in the backend and returns an identifier string.
CreateUser(ctx context.Context, invitation *invitations.Invitation) (string, error)
// CanSendMail should return true if the backend can send mail
CanSendMail() bool
// SendMail sends a mail to the user with details on how to reedeem the invitation.
SendMail(ctx context.Context, identifier string) error
}
// New returns a new instance of Service
func New(opts ...Option) (Service, error) {
options := newOptions(opts...)
urlTemplate, err := template.New("invitations-provisioning-endpoint-url").Parse(options.Config.Endpoint.URL)
bodyTemplate, err := template.New("invitations-provisioning-endpoint-url").Parse(options.Config.Endpoint.BodyTemplate)
if err != nil {
return nil, err
}
// Harcode keycloak backend for now, but this should be configurable in the future.
backend := keycloak.New(
options.Logger,
options.Config.Keycloak.BasePath,
options.Config.Keycloak.ClientID,
options.Config.Keycloak.ClientSecret,
options.Config.Keycloak.ClientRealm,
options.Config.Keycloak.UserRealm,
options.Config.Keycloak.InsecureSkipVerify,
)
return svc{
log: options.Logger,
config: options.Config,
urlTemplate: urlTemplate,
bodyTemplate: bodyTemplate,
log: options.Logger,
config: options.Config,
backend: backend,
}, nil
}
type svc struct {
config *config.Config
log log.Logger
urlTemplate *template.Template
bodyTemplate *template.Template
config *config.Config
log log.Logger
backend Backend
}
// Invite implements the service interface
func (s svc) Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) {
if invitation == nil {
return nil, ErrBadRequest
}
@@ -75,85 +82,19 @@ func (s svc) Invite(ctx context.Context, invitation *invitations.Invitation) (*i
return nil, ErrMissingEmail
}
user := &libregraph.User{
Mail: &invitation.InvitedUserEmailAddress,
// TODO we cannot set the user type here
}
if invitation.InvitedUserDisplayName != "" {
user.DisplayName = &invitation.InvitedUserDisplayName
}
// we don't really need a username as guests have to log in with their email address anyway
// what if later a user is provisioned with a guest accounts email address?
templateVars := map[string]string{
"redirectUrl": invitation.InviteRedirectUrl,
// TODO message and other options
"mail": invitation.InvitedUserEmailAddress,
"displayName": invitation.InvitedUserDisplayName,
"userType": invitation.InvitedUserType,
}
var urlWriter strings.Builder
if err := s.urlTemplate.Execute(&urlWriter, templateVars); err != nil {
return nil, err
}
var bodyWriter strings.Builder
if err := s.bodyTemplate.Execute(&bodyWriter, templateVars); err != nil {
return nil, err
}
// send a request to the provisioning endpoint
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true /*TODO make configurable*/},
}
client := &http.Client{Transport: tr}
req, err := http.NewRequest(s.config.Endpoint.Method, urlWriter.String(), bytes.NewBufferString(bodyWriter.String()))
id, err := s.backend.CreateUser(ctx, invitation)
if err != nil {
return nil, err
return nil, fmt.Errorf("%w: %s", ErrBackend, err)
}
// TODO either forward current user token or use bearer token?
switch s.config.Endpoint.Authorization {
case "token":
// TODO forward current reva access token
case "bearer":
req.Header.Set("Authorization", "Bearer "+s.config.Endpoint.Token)
default:
return nil, fmt.Errorf("unknown authorization: " + s.config.Endpoint.Authorization)
// As we only have a single backend, and that backend supports email, we don't have
// any code to handle mailing ourself yet.
if s.backend.CanSendMail() {
err := s.backend.SendMail(ctx, id)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrBackend, err)
}
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
// TODO hm ok so we expect the rosponse to be a libregraph user ... so much for a generic endpoint
// we could try parsing into a map[string]interface{} .... hm ... maybe better to be specific about
// the actual backend: libregraph, keycloak, scim or even oc10?
// Or we remember the mail of the user in memory and try to check if the user is already avilable via
// a local user api ... hm ... graph or cs3 user backend now?
// in any case this will require an additional endpoint to keep track of the ongoing invitations
invitedUser := &libregraph.User{}
err = json.NewDecoder(res.Body).Decode(invitedUser)
if err != nil {
return nil, err
}
response := &invitations.Invitation{
InvitedUser: invitedUser,
}
if res.StatusCode == http.StatusCreated {
response.Status = "Completed"
}
// optionally send an email
return response, nil
return invitation, nil
}