diff --git a/Gopkg.lock b/Gopkg.lock index db673103..83e496ed 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -19,7 +19,7 @@ [[projects]] branch = "master" - digest = "1:bcc4a183598d5be200dd8c0389deb82fc9aea3dc74515c7943b5c26aadc4fff9" + digest = "1:3891614a9c07e7eaed78766bdcf50a7eab97ed11b1e0a36470c993a78aacef5c" name = "github.com/cloudtrust/common-service" packages = [ ".", @@ -37,15 +37,15 @@ "tracking", ] pruneopts = "UT" - revision = "739cff91a05f8dbcf3a0c4e6e57b95089f2a052b" + revision = "36655fd025a52c74a067ad7acebccf9c81ea28b4" [[projects]] branch = "master" - digest = "1:13bd4c8cf09397bd6d76e175d935dd431ab63de35f1003c7456f23b2c9239743" + digest = "1:04181db7692c27a1c9c841f9ebc8439e7682a47d07a055d7b89bc90e533dff3a" name = "github.com/cloudtrust/keycloak-client" packages = ["."] pruneopts = "UT" - revision = "a7d644415b225bbf745c6d0d9b89d673583a5d3a" + revision = "01eb0fdf5d941b645e80233a39e46b6313efd560" [[projects]] digest = "1:d64c893fc7d2c3d395f421b00d21f0adb8ceffc4d3c90299e732b3985ca16eb4" @@ -122,12 +122,12 @@ version = "v1.5.0" [[projects]] - digest = "1:be408f349cae090a7c17a279633d6e62b00068e64af66a582cae0983de8890ea" + digest = "1:7ae311278f7ccaa724de8f2cdec0a507ba3ee6dea8c77237e8157bcf64b0f28b" name = "github.com/golang/mock" packages = ["gomock"] pruneopts = "UT" - revision = "9fa652df1129bef0e734c9cf9bf6dbae9ef3b9fa" - version = "1.3.1" + revision = "3dcdcb6994c4de42a73bd2e4790178be3ed4554b" + version = "v1.4.0" [[projects]] digest = "1:573ca21d3669500ff845bdebee890eb7fc7f0f50c59f2132f2a0c6b03d85086a" @@ -138,7 +138,7 @@ version = "v1.3.2" [[projects]] - digest = "1:e02b687ade0c19c038ecce3ea326d756519adc4bba1203a9e76d620b12354892" + digest = "1:debcbc62c53851dab1fc881438a65449dd5ffa12f64f059fd6e32b938799513d" name = "github.com/google/flatbuffers" packages = ["go"] pruneopts = "UT" @@ -173,7 +173,7 @@ version = "v1.0.0" [[projects]] - digest = "1:9b73396bc7a21f88702fe3d314ca7860843c08f9c787bb47f008075f840df23d" + digest = "1:c266355b17e65dc0128f4a9df906567b28e135b774e0b395aab3d748af0871e2" name = "github.com/influxdata/influxdb" packages = [ "client/v2", @@ -212,6 +212,14 @@ revision = "3536a929edddb9a5b34bd6861dc4a9647cb459fe" version = "v1.1.2" +[[projects]] + digest = "1:725947f73517dbc6592fb0c51d88fc3f7a7dfc4c2735499c3e4f42ddbc1170be" + name = "github.com/nyaruka/phonenumbers" + packages = ["."] + pruneopts = "UT" + revision = "0eaac607c796da2841fc99313671f128a9d4868c" + version = "v1.0.54" + [[projects]] digest = "1:11e62d6050198055e6cd87ed57e5d8c669e84f839c16e16f192374d913d1a70d" name = "github.com/opentracing/opentracing-go" @@ -418,11 +426,11 @@ [[projects]] branch = "master" - digest = "1:e13350b7207ecc34977340d53522e0335dd36ed86ca7c346a36d2e88eb0fadfc" + digest = "1:8a44970c7e8c0a1c8646af14605c1ffd31374074d68101c2e11d7761df12c9d1" name = "golang.org/x/sys" packages = ["unix"] pruneopts = "UT" - revision = "655fe14d7479994387fe12003042545d0c2859a5" + revision = "e047566fdf82409bf7a52212cf71df83ea2772fb" [[projects]] digest = "1:8d8faad6b12a3a4c819a3f9618cb6ee1fa1cfc33253abeeea8b55336721e3405" @@ -468,7 +476,7 @@ "go/types/typeutil", ] pruneopts = "UT" - revision = "eb0d8dd85bcce4e3c0c1da6bbc384d5ad84035bf" + revision = "593de606220b6283bd14765aab1e244ea650cd84" [[projects]] digest = "1:e0a1881f9e0564bebdeac98c75e59f07acdde6ed3a5396e0e613eaad31194866" @@ -530,12 +538,12 @@ version = "v2.4.1" [[projects]] - digest = "1:b75b3deb2bce8bc079e16bb2aecfe01eb80098f5650f9e93e5643ca8b7b73737" + digest = "1:55b110c99c5fdc4f14930747326acce56b52cfce60b24b1c03ef686ac0e46bb1" name = "gopkg.in/yaml.v2" packages = ["."] pruneopts = "UT" - revision = "1f64d6156d11335c3f22d9330b0ad14fc1e789ce" - version = "v2.2.7" + revision = "53403b58ad1b561927d19068c655246f2db79d48" + version = "v2.2.8" [solve-meta] analyzer-name = "dep" @@ -566,6 +574,7 @@ "github.com/gorilla/mux", "github.com/influxdata/influxdb/client/v2", "github.com/lib/pq", + "github.com/nyaruka/phonenumbers", "github.com/pkg/errors", "github.com/rs/cors", "github.com/spf13/pflag", diff --git a/README.md b/README.md index db74f972..48f6ed48 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Key | Description | Default value internal-http-host-port | HTTP server listening address | 0.0.0.0:8888 management-http-host-port | HTTP server listening address | 0.0.0.0:8877 account-http-host-port | HTTP server listening address | 0.0.0.0:8866 +register-http-host-port | HTTP server listening address | 0.0.0.0:8855 ### Keycloak @@ -66,12 +67,17 @@ Some parameters can be overridden with following ENV variables: ENV Variable | Parameter --- | ----------- +CT_BRIDGE_REGISTER_USERNAME | register-techuser-username +CT_BRIDGE_REGISTER_PASSWORD | register-techuser-password +CT_BRIDGE_REGISTER_CLIENT_ID | register-techuser-client-id CT_BRIDGE_DB_AUDIT_RW_USERNAME | db-audit-rw-username CT_BRIDGE_DB_AUDIT_RW_PASSWORD | db-audit-rw-password CT_BRIDGE_DB_AUDIT_RO_USERNAME | db-audit-ro-username CT_BRIDGE_DB_AUDIT_RO_PASSWORD | db-audit-ro-password CT_BRIDGE_DB_CONFIG_USERNAME | db-config-username CT_BRIDGE_DB_CONFIG_PASSWORD | db-config-password +CT_BRIDGE_DB_USERS_USERNAME | db-users-username +CT_BRIDGE_DB_USERS_PASSWORD | db-users-password CT_BRIDGE_INFLUX_USERNAME | influx-username CT_BRIDGE_INFLUX_PASSWORD | influx-password CT_BRIDGE_SENTRY_DSN | sentry-dsn @@ -145,7 +151,7 @@ correlation_id: ## Tests -Gomock is used to automatically genarate mocks. See the Cloudtrust [Gitbook](https://cloudtrust.github.io/doc/chapter-godevel/testing.html) for more information. +Gomock is used to automatically generate mocks. See the Cloudtrust [Gitbook](https://cloudtrust.github.io/doc/chapter-godevel/testing.html) for more information. The unit tests don't cover: diff --git a/api/register/api.go b/api/register/api.go new file mode 100644 index 00000000..2f442ab1 --- /dev/null +++ b/api/register/api.go @@ -0,0 +1,202 @@ +package apiregister + +import ( + "encoding/json" + "regexp" + "strings" + "time" + + cerrors "github.com/cloudtrust/common-service/errors" + kc "github.com/cloudtrust/keycloak-client" + "github.com/nyaruka/phonenumbers" +) + +// User representation +type User struct { + Gender *string `json:"gender,omitempty"` + FirstName *string `json:"firstName,omitempty"` + LastName *string `json:"lastName,omitempty"` + EmailAddress *string `json:"emailAddress,omitempty"` + PhoneNumber *string `json:"phoneNumber,omitempty"` + BirthDate *string `json:"birthDate,omitempty"` + BirthLocation *string `json:"birthLocation,omitempty"` + IDDocumentType *string `json:"idDocumentType,omitempty"` + IDDocumentNumber *string `json:"idDocumentNumber,omitempty"` + IDDocumentExpiration *string `json:"idDocumentExpiration,omitempty"` +} + +// DBUser struct +type DBUser struct { + UserID *string `json:"-"` + BirthLocation *string `json:"birth_location,omitempty"` + IDDocumentType *string `json:"id_document_typ,omitempty"` + IDDocumentNumber *string `json:"id_document_num,omitempty"` + IDDocumentExpiration *string `json:"id_document_exp,omitempty"` +} + +// Parameter references +const ( + prmUserGender = "user_gender" + prmUserFirstName = "user_firstName" + prmUserLastName = "user_lastName" + prmUserEmail = "user_emailAddress" + prmUserPhoneNumber = "user_phoneNumber" + prmUserBirthDate = "user_birthDate" + prmUserBirthLocation = "user_birthLocation" + prmUserIDDocumentType = "user_idDocType" + prmUserIDDocumentNumber = "user_idDocNumber" + prmUserIDDocumentExpiration = "user_idDocExpiration" + + regExpNames = `^([\wàáâäçèéêëìíîïñòóôöùúûüß]+([ '-][\wàáâäçèéêëìíîïñòóôöùúûüß]+)*){1,50}$` + regExpFirstName = regExpNames + regExpLastName = regExpNames + regExpEmail = `^.+\@.+\..+$` + regExpBirthLocation = regExpNames + // Multiple values with digits and letters separated by a single separator (space, dash) + regExpIDDocumentNumber = `^([\w\d]+([ -][\w\d]+)*){1,50}$` + + dateLayout = "02.01.2006" +) + +var ( + allowedGender = map[string]bool{"M": true, "F": true} + allowedDocumentType = map[string]bool{"Identity card": true, "Passport": true} +) + +// UserFromJSON creates a User using its json representation +func UserFromJSON(jsonRep string) (User, error) { + var user User + dec := json.NewDecoder(strings.NewReader(jsonRep)) + dec.DisallowUnknownFields() + err := dec.Decode(&user) + return user, err +} + +// UserToJSON returns a json representation of a given User +func (u *User) UserToJSON() string { + var bytes, _ = json.Marshal(u) + return string(bytes) +} + +// UpdateUserRepresentation converts a given User to a Keycloak UserRepresentation +func (u *User) UpdateUserRepresentation(kcUser *kc.UserRepresentation) { + var ( + bFalse = false + attributes = make(map[string][]string) + ) + + if u.Gender != nil { + attributes["gender"] = []string{*u.Gender} + } + if u.PhoneNumber != nil { + attributes["phoneNumber"] = []string{*u.PhoneNumber} + attributes["phoneNumberVerified"] = []string{"false"} + } + if u.BirthDate != nil { + attributes["birthDate"] = []string{*u.BirthDate} + } + + kcUser.Email = u.EmailAddress + kcUser.EmailVerified = &bFalse + kcUser.Enabled = &bFalse + kcUser.FirstName = u.FirstName + kcUser.LastName = u.LastName + kcUser.Attributes = &attributes +} + +// Validate checks the validity of the given User +func (u *User) Validate() error { + var err = validateParameterIn(prmUserGender, u.Gender, allowedGender, true) + if err != nil { + return err + } + err = validateParameterRegExp(prmUserFirstName, u.FirstName, regExpFirstName, true) + if err != nil { + return err + } + err = validateParameterRegExp(prmUserLastName, u.LastName, regExpLastName, true) + if err != nil { + return err + } + err = validateParameterRegExp(prmUserEmail, u.EmailAddress, regExpEmail, true) + if err != nil { + return err + } + err = validateParameterPhoneNumber(prmUserPhoneNumber, u.PhoneNumber) + if err != nil { + return err + } + err = validateParameterDate(prmUserBirthDate, u.BirthDate, false) + if err != nil { + return err + } + err = validateParameterRegExp(prmUserBirthLocation, u.BirthLocation, regExpBirthLocation, false) + if err != nil { + return err + } + err = validateParameterIn(prmUserIDDocumentType, u.IDDocumentType, allowedDocumentType, false) + if err != nil { + return err + } + err = validateParameterRegExp(prmUserIDDocumentNumber, u.IDDocumentNumber, regExpIDDocumentNumber, false) + if err != nil { + return err + } + err = validateParameterDate(prmUserIDDocumentExpiration, u.IDDocumentExpiration, false) + if err != nil { + return err + } + return nil +} + +func validateParameterIn(prmName string, value *string, allowedValues map[string]bool, mandatory bool) error { + if value == nil { + if mandatory { + return cerrors.CreateMissingParameterError(prmName) + } + } else { + if _, ok := allowedValues[*value]; !ok { + return cerrors.CreateBadRequestError(cerrors.MsgErrInvalidParam + "." + prmName) + } + } + return nil +} + +func validateParameterRegExp(prmName string, value *string, regExp string, mandatory bool) error { + if value == nil { + if mandatory { + return cerrors.CreateMissingParameterError(prmName) + } + } else { + res, _ := regexp.MatchString(regExp, strings.ToLower(*value)) + if !res { + return cerrors.CreateBadRequestError(cerrors.MsgErrInvalidParam + "." + prmName) + } + } + return nil +} + +func validateParameterPhoneNumber(prmName string, value *string) error { + if value == nil { + return cerrors.CreateMissingParameterError(prmName) + } + var metadata, err = phonenumbers.Parse(*value, "CH") + if err != nil || !phonenumbers.IsPossibleNumber(metadata) { + return cerrors.CreateBadRequestError(cerrors.MsgErrInvalidParam + "." + prmName) + } + return nil +} + +func validateParameterDate(prmName string, value *string, mandatory bool) error { + if value == nil { + if mandatory { + return cerrors.CreateMissingParameterError(prmName) + } + } else { + var _, err = time.Parse(dateLayout, *value) + if err != nil { + return cerrors.CreateBadRequestError(cerrors.MsgErrInvalidParam + "." + prmName) + } + } + return nil +} diff --git a/api/register/api_test.go b/api/register/api_test.go new file mode 100644 index 00000000..a8684a2c --- /dev/null +++ b/api/register/api_test.go @@ -0,0 +1,119 @@ +package apiregister + +import ( + "testing" + + kc "github.com/cloudtrust/keycloak-client" + "github.com/stretchr/testify/assert" +) + +func createValidUser() User { + var ( + gender = "M" + firstName = "Marc" + lastName = "El-Bichoun" + email = "marcel.bichon@elca.ch" + phoneNumber = "00 33 686 550011" + birthDate = "29.02.2020" + birthLocation = "Bermuda" + idDocType = "Passport" + idDocNumber = "123456789" + idDocExpiration = "23.02.2039" + ) + + return User{ + Gender: &gender, + FirstName: &firstName, + LastName: &lastName, + EmailAddress: &email, + PhoneNumber: &phoneNumber, + BirthDate: &birthDate, + BirthLocation: &birthLocation, + IDDocumentType: &idDocType, + IDDocumentNumber: &idDocNumber, + IDDocumentExpiration: &idDocExpiration, + } +} + +func TestJSON(t *testing.T) { + var user1 = createValidUser() + var j = user1.UserToJSON() + + var user2, err = UserFromJSON(j) + assert.Nil(t, err) + assert.Equal(t, user1, user2) + + _, err = UserFromJSON(`{gender="M",`) + assert.NotNil(t, err) + _, err = UserFromJSON(`{gender="M", unknownField=5}`) + assert.NotNil(t, err) +} + +func TestToKeycloakUser(t *testing.T) { + var user = createValidUser() + var kcUser = kc.UserRepresentation{} + + user.UpdateUserRepresentation(&kcUser) + + assert.Equal(t, user.FirstName, kcUser.FirstName) + assert.Equal(t, user.LastName, kcUser.LastName) + assert.Equal(t, user.EmailAddress, kcUser.Email) + assert.False(t, *kcUser.EmailVerified) + assert.False(t, *kcUser.Enabled) +} + +func TestValidateParameterIn(t *testing.T) { + var ( + empty = "" + user = createValidUser() + invalidDate = "29.02.2019" + ) + + t.Run("Valid users", func(t *testing.T) { + var users = []User{user, user, user, user, user, user} + users[1].BirthDate = nil + users[2].BirthLocation = nil + users[3].IDDocumentType = nil + users[4].IDDocumentNumber = nil + users[5].IDDocumentExpiration = nil + for idx, aUser := range users { + assert.Nil(t, aUser.Validate(), "User is expected to be valid. Test #%d failed with user %s", idx, aUser.UserToJSON()) + } + }) + + t.Run("Invalid users", func(t *testing.T) { + var users = []User{user, user, user, user, user, user, user, user, user, user, user, user, user, user, user} + // invalid values + users[0].Gender = &empty + users[1].FirstName = &empty + users[2].LastName = &empty + users[3].EmailAddress = &empty + users[4].PhoneNumber = &empty + users[5].BirthDate = &invalidDate + users[6].BirthLocation = &empty + users[7].IDDocumentType = &empty + users[8].IDDocumentNumber = &empty + users[9].IDDocumentExpiration = &invalidDate + // mandatory parameters + users[10].Gender = nil + users[11].FirstName = nil + users[12].LastName = nil + users[13].EmailAddress = nil + users[14].PhoneNumber = nil + + for idx, aUser := range users { + assert.NotNil(t, aUser.Validate(), "User is expected to be invalid. Test #%d failed with user %s", idx, aUser.UserToJSON()) + } + }) +} + +func TestValidateParameterDate(t *testing.T) { + var invalidDate = "29.02.2019" + var validDate = "29.02.2020" + + assert.NotNil(t, validateParameterDate("date", nil, true)) + assert.Nil(t, validateParameterDate("date", nil, false)) + + assert.NotNil(t, validateParameterDate("date", &invalidDate, true)) + assert.Nil(t, validateParameterDate("date", &validDate, true)) +} diff --git a/api/register/swagger-api_register.yaml b/api/register/swagger-api_register.yaml new file mode 100644 index 00000000..cbcc534e --- /dev/null +++ b/api/register/swagger-api_register.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.1 +info: + title: Swagger Cloudtrust Register + description: 'Self-register API for Cloudtrust.' + version: 1.0.0 +servers: +- url: http://localhost:8855 +tags: +- name: Register + description: Self registering of a user +paths: + /register/user: + post: + tags: + - Register + summary: Create a user + security: + - BasicAuth: [recaptcha] + parameters: + - name: realm + in: query + description: realm name (not id!) + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 200: + description: successful operation. Returns the generated username + content: + application/json: + schema: + type: string +components: + schemas: + User: + type: object + required: [gender, firstName, lastName, emailAddress, phoneNumber] + properties: + gender: + type: string + firstName: + type: string + lastName: + type: string + emailAddress: + type: string + phoneNumber: + type: string + birthDate: + type: string + birthLocation: + type: string + idDocumentType: + type: string + idDocumentNumber: + type: string + idDocumentExpiration: + type: string + securitySchemes: + BasicAuth: + type: http + scheme: basic + openId: + type: openIdConnect + openIdConnectUrl: http://toto.com/.well-known/openid-configuration \ No newline at end of file diff --git a/cmd/keycloakb/keycloak_bridge.go b/cmd/keycloakb/keycloak_bridge.go index c8adaccf..6d2f25fe 100644 --- a/cmd/keycloakb/keycloak_bridge.go +++ b/cmd/keycloakb/keycloak_bridge.go @@ -34,6 +34,7 @@ import ( "github.com/cloudtrust/keycloak-bridge/pkg/events" "github.com/cloudtrust/keycloak-bridge/pkg/export" "github.com/cloudtrust/keycloak-bridge/pkg/management" + "github.com/cloudtrust/keycloak-bridge/pkg/register" "github.com/cloudtrust/keycloak-bridge/pkg/statistics" keycloak "github.com/cloudtrust/keycloak-client" "github.com/go-kit/kit/endpoint" @@ -105,6 +106,7 @@ func main() { httpAddrInternal = c.GetString("internal-http-host-port") httpAddrManagement = c.GetString("management-http-host-port") httpAddrAccount = c.GetString("account-http-host-port") + httpAddrRegister = c.GetString("register-http-host-port") // Keycloak keycloakConfig = keycloak.Config{ @@ -129,6 +131,9 @@ func main() { configRwDbParams = database.GetDbConfig(c, "db-config-rw", !c.GetBool("config-db-rw")) configRoDbParams = database.GetDbConfig(c, "db-config-ro", !c.GetBool("config-db-ro")) + // DB for users + usersRwDbParams = database.GetDbConfig(c, "db-users-rw", !c.GetBool("users-db-rw")) + // Rate limiting rateLimit = map[string]int{ "event": c.GetInt("rate-event"), @@ -136,6 +141,7 @@ func main() { "management": c.GetInt("rate-management"), "statistics": c.GetInt("rate-statistics"), "events": c.GetInt("rate-events"), + "register": c.GetInt("rate-register"), } corsOptions = cors.Options{ @@ -151,6 +157,14 @@ func main() { // Access logs accessLogsEnabled = c.GetBool("access-logs") + + // Register parameters + registerEnabled = c.GetBool("register-enabled") + registerRealm = c.GetString("register-realm") + registerUsername = c.GetString("register-techuser-username") + registerPassword = c.GetString("register-techuser-password") + registerClientID = c.GetString("register-techuser-client-id") + recaptchaURL = c.GetString("recaptcha-url") ) // Unique ID generator @@ -304,6 +318,16 @@ func main() { } } + var usersRwDBConn sqltypes.CloudtrustDB + { + var err error + usersRwDBConn, err = database.NewReconnectableCloudtrustDB(usersRwDbParams) + if err != nil { + logger.Error(ctx, "msg", "could not create DB connection for users (RW)", "error", err) + return + } + } + // Health check configuration var healthChecker = healthcheck.NewHealthChecker(keycloakb.ComponentName, logger) var healthCheckCacheDuration = c.GetDuration("livenessprobe-cache-duration") * time.Millisecond @@ -438,11 +462,7 @@ func main() { eventsDBModule := configureEventsDbModule(baseEventsDBModule, influxMetrics, managementLogger, tracer) // module for storing and retrieving the custom configuration - var configDBModule management.ConfigurationDBModule - { - configDBModule = keycloakb.NewConfigurationDBModule(configurationRwDBConn, managementLogger) - configDBModule = keycloakb.MakeConfigurationDBModuleInstrumentingMW(influxMetrics.NewHistogram("configDB_module"))(configDBModule) - } + var configDBModule = createConfigurationDBModule(configurationRwDBConn, influxMetrics, managementLogger) var keycloakComponent management.Component { @@ -530,6 +550,39 @@ func main() { } } + // Create OIDC token provider and validate technical user credentials + var oidcTokenProvider keycloak.OidcTokenProvider + { + oidcTokenProvider = keycloak.NewOidcTokenProvider(keycloakConfig, registerRealm, registerUsername, registerPassword, registerClientID, logger) + var _, err = oidcTokenProvider.ProvideToken(context.Background()) + if err != nil { + logger.Warn(context.Background(), "msg", "OIDC token provider validation failed for technical user", "err", err.Error()) + } + } + + // Register service. + var registerEndpoints register.Endpoints + { + var registerLogger = log.With(logger, "svc", "register") + + // Configure events db module + eventsDBModule := configureEventsDbModule(baseEventsDBModule, influxMetrics, registerLogger, tracer) + + // module for storing and retrieving the custom configuration + var configDBModule = createConfigurationDBModule(configurationRwDBConn, influxMetrics, registerLogger) + + // module for storing and retrieving details of the self-registered users + var usersDBModule = register.NewUsersDBModule(usersRwDBConn, registerLogger) + + // new module for register service + registerComponent := register.NewComponent(registerRealm, keycloakClient, oidcTokenProvider, usersDBModule, configDBModule, eventsDBModule, registerLogger) + registerComponent = register.MakeAuthorizationRegisterComponentMW(log.With(registerLogger, "mw", "endpoint"))(registerComponent) + + registerEndpoints = register.Endpoints{ + RegisterUser: prepareEndpoint(register.MakeRegisterUserEndpoint(registerComponent), "register_user", influxMetrics, registerLogger, tracer, rateLimit["register"]), + } + } + // Export configuration var exportModule = export.NewModule(keycloakClient, logger) var cfgStorageModue = export.NewConfigStorageModule(eventsDBConn) @@ -793,6 +846,36 @@ func main() { errc <- http.ListenAndServe(httpAddrAccount, handler) }() + // HTTP register Server (Register API). + if registerEnabled { + go func() { + var logger = log.With(logger, "transport", "http") + logger.Info(ctx, "addr", httpAddrRegister) + + var route = mux.NewRouter() + + // Version. + route.Handle("/", http.HandlerFunc(commonhttp.MakeVersionHandler(keycloakb.ComponentName, ComponentID, keycloakb.Version, Environment, GitCommit))) + route.Handle("/health/check", healthChecker.MakeHandler()) + + // Register + var registerUserHandler = configureRegisterHandler(keycloakb.ComponentName, ComponentID, idGenerator, keycloakClient, recaptchaURL, tracer, logger)(registerEndpoints.RegisterUser) + + route.Path("/register/user").Methods("POST").Handler(registerUserHandler) + + var handler http.Handler = route + + if accessLogsEnabled { + handler = commonhttp.MakeAccessLogHandler(accessLogger, handler) + } + + c := cors.New(corsOptions) + handler = c.Handler(handler) + + errc <- http.ListenAndServe(httpAddrRegister, handler) + }() + } + // Influx writing. go func() { var tic = time.NewTicker(influxWriteInterval) @@ -823,6 +906,7 @@ func config(ctx context.Context, logger log.Logger) *viper.Viper { v.SetDefault("internal-http-host-port", "0.0.0.0:8888") v.SetDefault("management-http-host-port", "0.0.0.0:8877") v.SetDefault("account-http-host-port", "0.0.0.0:8866") + v.SetDefault("register-http-host-port", "0.0.0.0:8855") // Security - Audience check v.SetDefault("audience-required", "") @@ -862,12 +946,20 @@ func config(ctx context.Context, logger log.Logger) *viper.Viper { v.SetDefault("db-config-ro-migration", false) v.SetDefault("db-config-ro-migration-version", "") + //Storage users in DB (read/write) + v.SetDefault("users-db-rw", true) + database.ConfigureDbDefault(v, "db-users-rw", "CT_BRIDGE_DB_USERS_RW_USERNAME", "CT_BRIDGE_DB_USERS_RW_PASSWORD") + + v.SetDefault("db-users-rw-migration", false) + v.SetDefault("db-users-rw-migration-version", "") + // Rate limiting (in requests/second) v.SetDefault("rate-event", 1000) v.SetDefault("rate-account", 1000) v.SetDefault("rate-management", 1000) v.SetDefault("rate-statistics", 1000) v.SetDefault("rate-events", 1000) + v.SetDefault("rate-register", 1000) // Influx DB client default. v.SetDefault("influx", false) @@ -899,6 +991,14 @@ func config(ctx context.Context, logger log.Logger) *viper.Viper { v.SetDefault("livenessprobe-http-timeout", 900) v.SetDefault("livenessprobe-cache-duration", 500) + // Register parameters + v.SetDefault("register-enabled", false) + v.SetDefault("register-realm", "trustid") + v.SetDefault("register-techuser-username", "") + v.SetDefault("register-techuser-password", "") + v.SetDefault("register-techuser-client-id", "") + v.SetDefault("recaptcha-url", "https://www.google.com/recaptcha/api/siteverify") + // First level of override. pflag.String("config-file", v.GetString("config-file"), "The configuration file path can be relative or absolute.") pflag.String("authorization-file", v.GetString("authorization-file"), "The authorization file path can be relative or absolute.") @@ -910,6 +1010,9 @@ func config(ctx context.Context, logger log.Logger) *viper.Viper { // We use env variables to bind Openshift secrets var censoredParameters = map[string]bool{} + v.BindEnv("register-techuser-username", "CT_BRIDGE_REGISTER_USERNAME") + v.BindEnv("register-techuser-password", "CT_BRIDGE_REGISTER_PASSWORD") + v.BindEnv("influx-username", "CT_BRIDGE_INFLUX_USERNAME") v.BindEnv("influx-password", "CT_BRIDGE_INFLUX_PASSWORD") censoredParameters["influx-password"] = true @@ -996,6 +1099,25 @@ func configureAccountHandler(ComponentName string, ComponentID string, idGenerat } } +func configureRegisterHandler(ComponentName string, ComponentID string, idGenerator idgenerator.IDGenerator, keycloakClient *keycloak.Client, recaptchaURL string, tracer tracing.OpentracingClient, logger log.Logger) func(endpoint endpoint.Endpoint) http.Handler { + return func(endpoint endpoint.Endpoint) http.Handler { + var handler http.Handler + handler = register.MakeRegisterHandler(endpoint, logger) + handler = middleware.MakeHTTPCorrelationIDMW(idGenerator, tracer, logger, ComponentName, ComponentID)(handler) + handler = register.MakeHTTPRecaptchaValidationMW(recaptchaURL, logger)(handler) + return handler + } +} + +func createConfigurationDBModule(configDBConn sqltypes.CloudtrustDB, influxMetrics metrics.Metrics, logger log.Logger) keycloakb.ConfigurationDBModule { + var configDBModule keycloakb.ConfigurationDBModule + { + configDBModule = keycloakb.NewConfigurationDBModule(configDBConn, logger) + configDBModule = keycloakb.MakeConfigurationDBModuleInstrumentingMW(influxMetrics.NewHistogram("configDB_module"))(configDBModule) + } + return configDBModule +} + func configureEventsDbModule(baseEventsDBModule database.EventsDBModule, influxMetrics metrics.Metrics, logger log.Logger, tracer tracing.OpentracingClient) database.EventsDBModule { eventsDBModule := event.MakeEventsDBModuleInstrumentingMW(influxMetrics.NewHistogram("eventsDB_module"))(baseEventsDBModule) eventsDBModule = event.MakeEventsDBModuleLoggingMW(log.With(logger, "mw", "module", "unit", "eventsDB"))(eventsDBModule) diff --git a/configs/keycloak_bridge.yml b/configs/keycloak_bridge.yml index c90173bd..458981c3 100644 --- a/configs/keycloak_bridge.yml +++ b/configs/keycloak_bridge.yml @@ -5,6 +5,7 @@ internal-http-host-port: 0.0.0.0:8888 management-http-host-port: 0.0.0.0:8877 account-http-host-port: 0.0.0.0:8866 +register-http-host-port: 0.0.0.0:8855 # Log level # - error: log only error log level @@ -41,7 +42,6 @@ audience-required: "account" ## Password used to protect /internal/event endpoint event-basic-auth-token: "superpasswordverylongandstrong" - # Keycloak configs keycloak-api-uri: http://localhost:8080 keycloak-oidc-uri: http://localhost:8080 http://127.0.0.1:8080 @@ -104,6 +104,20 @@ db-config-ro-migration: false db-config-ro-migration-version: 0.1 db-config-ro-connection-check: true +# DB Users RW +db-users-rw-host-port: 172.17.0.2:3306 +db-users-rw-username: bridge +db-users-rw-password: bridge-password +db-users-rw-database: users +db-users-rw-protocol: tcp +db-users-rw-parameters: time_zone='%2B00:00' +db-users-rw-max-open-conns: 10 +db-users-rw-max-idle-conns: 2 +db-users-rw-conn-max-lifetime: 10 +db-users-rw-migration: false +db-users-rw-migration-version: 0.1 +db-users-rw-connection-check: true + # audit events events-db: false @@ -113,7 +127,7 @@ rate-account: 1000 rate-management: 1000 rate-statistics: 1000 rate-events: 1000 - +rate-register: 1000 # Influx DB configs influx: false @@ -142,5 +156,13 @@ jaeger-write-interval: 1s pprof-route-enabled: true # Liveness probe -livenessprobe-http-timeout: 5 -livenessprobe-cache-duration: 10 +livenessprobe-http-timeout: 900 +livenessprobe-cache-duration: 500 + +# Register API parameters +register-enabled: false +register-realm: trustid +register-techuser-username: technicaluser +register-techuser-password: technicalsuperpasswordverylongandstrong +register-techuser-client-id: admin-cli +recaptcha-url: https://www.google.com/recaptcha/api/siteverify \ No newline at end of file diff --git a/internal/dto/configuration.go b/internal/dto/configuration.go index e27a037e..e7e045b3 100644 --- a/internal/dto/configuration.go +++ b/internal/dto/configuration.go @@ -2,14 +2,15 @@ package dto // RealmConfiguration struct type RealmConfiguration struct { - DefaultClientID *string `json:"default_client_id"` - DefaultRedirectURI *string `json:"default_redirect_uri"` - APISelfAuthenticatorDeletionEnabled *bool `json:"api_self_authenticator_deletion_enabled"` - APISelfPasswordChangeEnabled *bool `json:"api_self_password_change_enabled"` - APISelfMailEditingEnabled *bool `json:"api_self_mail_editing_enabled"` - APISelfAccountDeletionEnabled *bool `json:"api_self_account_deletion_enabled"` - ShowAuthenticatorsTab *bool `json:"show_authenticators_tab"` - ShowPasswordTab *bool `json:"show_password_tab"` - ShowMailEditing *bool `json:"show_mail_editing"` - ShowAccountDeletionButton *bool `json:"show_account_deletion_button"` + DefaultClientID *string `json:"default_client_id,omitempty"` + DefaultRedirectURI *string `json:"default_redirect_uri,omitempty"` + APISelfAuthenticatorDeletionEnabled *bool `json:"api_self_authenticator_deletion_enabled,omitempty"` + APISelfPasswordChangeEnabled *bool `json:"api_self_password_change_enabled,omitempty"` + APISelfMailEditingEnabled *bool `json:"api_self_mail_editing_enabled,omitempty"` + APISelfAccountDeletionEnabled *bool `json:"api_self_account_deletion_enabled,omitempty"` + ShowAuthenticatorsTab *bool `json:"show_authenticators_tab,omitempty"` + ShowPasswordTab *bool `json:"show_password_tab,omitempty"` + ShowMailEditing *bool `json:"show_mail_editing,omitempty"` + ShowAccountDeletionButton *bool `json:"show_account_deletion_button,omitempty"` + RegisterExecuteActions *[]string `json:"register_execute_actions,omitempty"` } diff --git a/internal/keycloakb/mock_test.go b/internal/keycloakb/mock_test.go index 6425801c..087b0364 100644 --- a/internal/keycloakb/mock_test.go +++ b/internal/keycloakb/mock_test.go @@ -2,6 +2,5 @@ package keycloakb //go:generate mockgen -destination=./mock/instrumenting.go -package=mock -mock_names=Histogram=Histogram github.com/cloudtrust/common-service/metrics Histogram //go:generate mockgen -destination=./mock/configdbinstrumenting.go -package=mock -mock_names=ConfigurationDBModule=ConfigurationDBModule github.com/cloudtrust/keycloak-bridge/internal/keycloakb ConfigurationDBModule -//go:generate mockgen -destination=./mock/db.go -package=mock -mock_names=CloudtrustDB=CloudtrustDB github.com/cloudtrust/common-service/database/sqltypes CloudtrustDB //go:generate mockgen -destination=./mock/keycloak_client.go -package=mock -mock_names=KeycloakClient=KeycloakClient github.com/cloudtrust/keycloak-bridge/internal/keycloakb KeycloakClient -//go:generate mockgen -destination=./mock/sqltypes.go -package=mock -mock_names=SQLRow=SQLRow github.com/cloudtrust/common-service/database/sqltypes SQLRow +//go:generate mockgen -destination=./mock/sqltypes.go -package=mock -mock_names=CloudtrustDB=CloudtrustDB,SQLRow=SQLRow github.com/cloudtrust/common-service/database/sqltypes CloudtrustDB,SQLRow diff --git a/internal/messages/errormessages.go b/internal/messages/errormessages.go index b2622f8e..387a74ca 100644 --- a/internal/messages/errormessages.go +++ b/internal/messages/errormessages.go @@ -15,6 +15,7 @@ const ( MsgErrUnknown = "unknowError" MsgErrNotConfigured = "notConfigured" + BodyContent = "bodyContent" RealmConfiguration = "realmConfiguration" Authorization = "authorization" CurrentPassword = "currentPassword" diff --git a/pkg/register/authorization.go b/pkg/register/authorization.go new file mode 100644 index 00000000..aa731cd3 --- /dev/null +++ b/pkg/register/authorization.go @@ -0,0 +1,134 @@ +package register + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + errorhandler "github.com/cloudtrust/common-service/errors" + "github.com/cloudtrust/common-service/log" + apiregister "github.com/cloudtrust/keycloak-bridge/api/register" +) + +const ( + regexpBasicAuth = `^[Bb]asic (.+)$` + regExpRecaptcha = `^([\w\d]+):secret=(.+),token=(.+)$` +) + +type recaptchaResponse struct { + Success bool `json:"success"` + ChallengeTS string `json:"challenge_ts"` + Hostname string `json:"hostname"` + ErrorCodes []string `json:"error-codes"` +} + +// MakeHTTPRecaptchaValidationMW retrieves the recaptcha code and checks its validity +func MakeHTTPRecaptchaValidationMW(recaptchaURL string, logger log.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var authorizationHeader = req.Header.Get("Authorization") + var ctx = context.TODO() + + if authorizationHeader == "" { + logger.Info(ctx, "Authorization Error", "Missing Authorization header") + httpErrorHandler(ctx, http.StatusForbidden, errors.New(errorhandler.MsgErrMissingParam+"."+errorhandler.AuthHeader), w) + return + } + + var r = regexp.MustCompile(regexpBasicAuth) + var match = r.FindStringSubmatch(authorizationHeader) + if match == nil { + logger.Info(ctx, "Authorization Error", "Missing basic token") + httpErrorHandler(ctx, http.StatusForbidden, errors.New(errorhandler.MsgErrMissingParam+"."+errorhandler.BasicToken), w) + return + } + + // Decode base 64 (RegExp matched: we got exactly 2 values. match[0] is the global matched string, match[1] is the first group) + decodedToken, err := base64.StdEncoding.DecodeString(match[1]) + if err != nil { + logger.Info(ctx, "Authorization Error", "Invalid base64 token") + httpErrorHandler(ctx, http.StatusForbidden, errors.New(errorhandler.MsgErrInvalidParam+"."+errorhandler.Token), w) + return + } + + // Extract username & password values + r = regexp.MustCompile(regExpRecaptcha) + match = r.FindStringSubmatch(string(decodedToken)) + if match == nil { + logger.Info(ctx, "Authorization Error", "Invalid token format (recaptcha:secret={secret},token={token})") + httpErrorHandler(ctx, http.StatusForbidden, errors.New(errorhandler.MsgErrInvalidParam+"."+errorhandler.Token), w) + return + } + + if !checkRecaptcha(ctx, recaptchaURL, match[2], match[3], logger) { + httpErrorHandler(ctx, http.StatusForbidden, errors.New(errorhandler.MsgErrInvalidParam+"."+errorhandler.Token), w) + return + } + + next.ServeHTTP(w, req) + }) + } +} + +func httpErrorHandler(_ context.Context, statusCode int, err error, w http.ResponseWriter) { + w.WriteHeader(statusCode) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + w.Write([]byte(errorhandler.GetEmitter() + "." + err.Error())) +} + +func checkRecaptcha(ctx context.Context, recaptchaURL string, secret string, token string, logger log.Logger) bool { + var parameters = fmt.Sprintf("secret=%s&response=%s", url.QueryEscape(secret), url.QueryEscape(token)) + var resp, err = http.Post(recaptchaURL, "application/x-www-form-urlencoded", strings.NewReader(parameters)) + if err != nil { + logger.Warn(ctx, "msg", "Can't validate recaptcha token", "err", err.Error()) + return false + } + if resp.StatusCode != http.StatusOK { + logger.Warn(ctx, "msg", fmt.Sprintf("Recaptcha validation failed: http status %d", resp.StatusCode)) + return false + } + + buf := new(bytes.Buffer) + buf.ReadFrom(resp.Body) + + var recaptchaResponse recaptchaResponse + err = json.Unmarshal(buf.Bytes(), &recaptchaResponse) + if err != nil { + logger.Warn(ctx, "msg", "Recaptcha validation: can't deserialize response", "response", buf.Bytes()) + return false + } + + if !recaptchaResponse.Success { + logger.Warn(ctx, "msg", "Recaptcha validation: invalid token", "cause", recaptchaResponse.ErrorCodes) + } + + return recaptchaResponse.Success +} + +type authorizationComponentMW struct { + logger log.Logger + next Component +} + +// MakeAuthorizationRegisterComponentMW checks authorization and return an error if the action is not allowed. +func MakeAuthorizationRegisterComponentMW(logger log.Logger) func(Component) Component { + return func(next Component) Component { + return &authorizationComponentMW{ + logger: logger, + next: next, + } + } +} + +// authorizationComponentMW implements Component. +func (c *authorizationComponentMW) RegisterUser(ctx context.Context, realmName string, user apiregister.User) (string, error) { + return c.next.RegisterUser(ctx, realmName, user) +} diff --git a/pkg/register/authorization_test.go b/pkg/register/authorization_test.go new file mode 100644 index 00000000..e8ded997 --- /dev/null +++ b/pkg/register/authorization_test.go @@ -0,0 +1,124 @@ +package register + +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "testing" + + logger "github.com/cloudtrust/common-service/log" + apiregister "github.com/cloudtrust/keycloak-bridge/api/register" + "github.com/cloudtrust/keycloak-bridge/pkg/register/mock" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" +) + +func TestMakeHTTPRecaptchaValidationMW(t *testing.T) { + var mockCtrl = gomock.NewController(t) + defer mockCtrl.Finish() + + var mockHTTPHandler = mock.NewHandler(mockCtrl) + var mockRecaptchaHandler = mock.NewHandler(mockCtrl) + var mockResponseWriter = mock.NewResponseWriter(mockCtrl) + + var recaptchaPath = "/recaptcha" + r := mux.NewRouter() + r.Handle(recaptchaPath, mockRecaptchaHandler) + + ts := httptest.NewServer(r) + defer ts.Close() + + var authHandler = MakeHTTPRecaptchaValidationMW(ts.URL+recaptchaPath, logger.NewNopLogger())(mockHTTPHandler) + var req = http.Request{ + Header: make(http.Header), + } + + t.Run("Missing Basic authentication", func(t *testing.T) { + mockResponseWriter.EXPECT().WriteHeader(403) + mockResponseWriter.EXPECT().Header().Return(req.Header) + mockResponseWriter.EXPECT().Write(gomock.Any()) + authHandler.ServeHTTP(mockResponseWriter, &req) + }) + + t.Run("Not a valid Basic authentication", func(t *testing.T) { + req.Header.Set("Authorization", "Dont match regexp") + mockResponseWriter.EXPECT().WriteHeader(403) + mockResponseWriter.EXPECT().Header().Return(req.Header) + mockResponseWriter.EXPECT().Write(gomock.Any()) + authHandler.ServeHTTP(mockResponseWriter, &req) + }) + + t.Run("Basic authentication is not a base 64 value", func(t *testing.T) { + var invalidBase64 = "AB" + req.Header.Set("Authorization", "Basic "+invalidBase64) + mockResponseWriter.EXPECT().WriteHeader(403) + mockResponseWriter.EXPECT().Header().Return(req.Header) + mockResponseWriter.EXPECT().Write(gomock.Any()) + authHandler.ServeHTTP(mockResponseWriter, &req) + }) + + t.Run("Basic authentication decoded value is not like 'type:secret=qwerty,token=abcdef-789www'", func(t *testing.T) { + var basicAuthenticationValue = base64.StdEncoding.EncodeToString([]byte("admin=password")) + req.Header.Set("Authorization", "Basic "+basicAuthenticationValue) + mockResponseWriter.EXPECT().WriteHeader(403) + mockResponseWriter.EXPECT().Header().Return(req.Header) + mockResponseWriter.EXPECT().Write(gomock.Any()) + authHandler.ServeHTTP(mockResponseWriter, &req) + }) + + t.Run("Recaptcha bad HTTP status", func(t *testing.T) { + var basicAuthenticationValue = base64.StdEncoding.EncodeToString([]byte("recaptcha:secret=abcdef,token=123456")) + req.Header.Set("Authorization", "Basic "+basicAuthenticationValue) + mockRecaptchaHandler.EXPECT().ServeHTTP(gomock.Any(), gomock.Any()).Do(func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(400) + }) + mockResponseWriter.EXPECT().WriteHeader(403) + mockResponseWriter.EXPECT().Header().Return(req.Header) + mockResponseWriter.EXPECT().Write(gomock.Any()) + authHandler.ServeHTTP(mockResponseWriter, &req) + }) + + t.Run("Invalid recaptcha code", func(t *testing.T) { + var basicAuthenticationValue = base64.StdEncoding.EncodeToString([]byte("recaptcha:secret=abcdef,token=123456")) + req.Header.Set("Authorization", "Basic "+basicAuthenticationValue) + mockRecaptchaHandler.EXPECT().ServeHTTP(gomock.Any(), gomock.Any()).Do(func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"success":false}`)) + }) + mockResponseWriter.EXPECT().WriteHeader(403) + mockResponseWriter.EXPECT().Header().Return(req.Header) + mockResponseWriter.EXPECT().Write(gomock.Any()) + authHandler.ServeHTTP(mockResponseWriter, &req) + }) + + t.Run("Recaptcha code is valid", func(t *testing.T) { + var basicAuthenticationValue = base64.StdEncoding.EncodeToString([]byte("recaptcha:secret=abcdef,token=abcdef")) + req.Header.Set("Authorization", "Basic "+basicAuthenticationValue) + mockRecaptchaHandler.EXPECT().ServeHTTP(gomock.Any(), gomock.Any()).Do(func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"success":true}`)) + }) + mockHTTPHandler.EXPECT().ServeHTTP(gomock.Any(), gomock.Any()) + authHandler.ServeHTTP(mockResponseWriter, &req) + }) +} + +func TestMakeAuthorizationRegisterComponentMW(t *testing.T) { + var mockCtrl = gomock.NewController(t) + defer mockCtrl.Finish() + + var mockComponent = mock.NewComponent(mockCtrl) + var component = MakeAuthorizationRegisterComponentMW(logger.NewNopLogger())(mockComponent) + + var ctx = context.TODO() + var realm = "master" + var user = apiregister.User{} + var expectedErr = errors.New("") + + mockComponent.EXPECT().RegisterUser(ctx, realm, user).Return("", expectedErr).Times(1) + var _, err = component.RegisterUser(ctx, realm, user) + assert.Equal(t, expectedErr, err) +} diff --git a/pkg/register/component.go b/pkg/register/component.go new file mode 100644 index 00000000..5ea33b45 --- /dev/null +++ b/pkg/register/component.go @@ -0,0 +1,202 @@ +package register + +import ( + "context" + "math/rand" + "net/http" + "regexp" + "strings" + + "github.com/cloudtrust/keycloak-client" + + "github.com/cloudtrust/common-service/database" + errorhandler "github.com/cloudtrust/common-service/errors" + apiregister "github.com/cloudtrust/keycloak-bridge/api/register" + "github.com/cloudtrust/keycloak-bridge/internal/dto" + internal "github.com/cloudtrust/keycloak-bridge/internal/keycloakb" + kc "github.com/cloudtrust/keycloak-client" +) + +// KeycloakClient are methods from keycloak-client used by this component +type KeycloakClient interface { + CreateUser(accessToken string, realmName string, targetRealmName string, user kc.UserRepresentation) (string, error) + UpdateUser(accessToken string, realmName, userID string, user kc.UserRepresentation) error + GetUsers(accessToken string, reqRealmName, targetRealmName string, paramKV ...string) (kc.UsersPageRepresentation, error) + ExecuteActionsEmail(accessToken string, realmName string, userID string, actions []string, paramKV ...string) error +} + +// ConfigurationDBModule is the interface of the configuration module. +type ConfigurationDBModule interface { + GetConfiguration(context.Context, string) (dto.RealmConfiguration, error) +} + +// Component is the register component interface. +type Component interface { + RegisterUser(ctx context.Context, realmName string, user apiregister.User) (string, error) +} + +// Component is the management component. +type component struct { + realm string + keycloakClient KeycloakClient + tokenProvider keycloak.OidcTokenProvider + usersDBModule UsersDBModule + configDBModule ConfigurationDBModule + eventsDBModule database.EventsDBModule + logger internal.Logger +} + +// NewComponent returns the management component. +func NewComponent(realm string, keycloakClient KeycloakClient, tokenProvider keycloak.OidcTokenProvider, usersDBModule UsersDBModule, configDBModule ConfigurationDBModule, eventsDBModule database.EventsDBModule, logger internal.Logger) Component { + return &component{ + realm: realm, + keycloakClient: keycloakClient, + tokenProvider: tokenProvider, + usersDBModule: usersDBModule, + configDBModule: configDBModule, + eventsDBModule: eventsDBModule, + logger: logger, + } +} + +func (c *component) reportEvent(ctx context.Context, apiCall string, values ...string) { + errEvent := c.eventsDBModule.ReportEvent(ctx, apiCall, "back-office", values...) + if errEvent != nil { + //store in the logs also the event that failed to be stored in the DB + internal.LogUnrecordedEvent(ctx, c.logger, apiCall, errEvent.Error(), values...) + } +} + +func (c *component) RegisterUser(ctx context.Context, realmName string, user apiregister.User) (string, error) { + // Validate input request + var err = user.Validate() + if err != nil { + c.logger.Info(ctx, "err", err.Error()) + return "", err + } + + // Get Realm configuration from database + var realmConf dto.RealmConfiguration + realmConf, err = c.configDBModule.GetConfiguration(ctx, realmName) + if err != nil { + c.logger.Info(ctx, "msg", "Can't get realm configuration from database", "err", err.Error()) + return "", err + } + + // Get an OIDC token to be able to request Keycloak + var accessToken string + accessToken, err = c.tokenProvider.ProvideToken(ctx) + if err != nil { + c.logger.Warn(ctx, "msg", "Can't get OIDC token", "err", err.Error()) + return "", err + } + + // Registering should be disallowed if an enabled user already exists with the same email + var kcUser *kc.UserRepresentation + kcUser, err = c.checkExistingUser(ctx, accessToken, user) + if err != nil { + return "", err + } + + var userID string + if kcUser == nil { + var chars = []rune("0123456789") + for i := 0; i < 10; i++ { + var username = c.generateUsername(chars, 8) + + // Create the user in Keycloak + kcUser = &kc.UserRepresentation{} + kcUser.Username = &username + user.UpdateUserRepresentation(kcUser) + userID, err = c.keycloakClient.CreateUser(accessToken, c.realm, c.realm, *kcUser) + // Create success: just have to get the userID and exist this loop + if err == nil { + var re = regexp.MustCompile(`(^.*/users/)`) + userID = re.ReplaceAllString(userID, "") + break + } + userID = "" + switch e := err.(type) { + case errorhandler.Error: + if e.Status == http.StatusConflict && e.Message == "keycloak.existing.username" { + // Username already exists + continue + } + } + c.logger.Warn(ctx, "msg", "Failed to create user through Keycloak API", "err", err.Error()) + return "", err + } + if userID == "" { + c.logger.Warn(ctx, "msg", "Can't generate unused username after multiple attempts") + return "", errorhandler.CreateInternalServerError("username.generation") + } + } else { + userID = *kcUser.Id + user.UpdateUserRepresentation(kcUser) + err = c.keycloakClient.UpdateUser(accessToken, c.realm, userID, *kcUser) + if err != nil { + c.logger.Warn(ctx, "msg", "Failed to update user through Keycloak API", "err", err.Error()) + return "", err + } + } + + // Store user in database + err = c.usersDBModule.StoreOrUpdateUser(ctx, c.realm, apiregister.DBUser{ + UserID: &userID, + BirthLocation: user.BirthLocation, + IDDocumentType: user.IDDocumentType, + IDDocumentNumber: user.IDDocumentNumber, + IDDocumentExpiration: user.IDDocumentExpiration, + }) + if err != nil { + c.logger.Warn(ctx, "msg", "Can't store user details in database", "err", err.Error()) + return "", err + } + + // Send execute actions email + if realmConf.RegisterExecuteActions != nil && len(*realmConf.RegisterExecuteActions) > 0 { + err = c.keycloakClient.ExecuteActionsEmail(accessToken, c.realm, userID, *realmConf.RegisterExecuteActions) + if err != nil { + c.logger.Warn(ctx, "msg", "ExecuteActionsEmail failed", "err", err.Error()) + return "", err + } + } + + // store the API call into the DB + c.reportEvent(ctx, "REGISTER_USER", database.CtEventRealmName, c.realm, database.CtEventUserID, userID) + + return *kcUser.Username, nil +} + +// Check if a user already exists in Keycloak... If such a user exists in database, he can register himself only if the existing user is not yet enabled +func (c *component) checkExistingUser(ctx context.Context, accessToken string, user apiregister.User) (*kc.UserRepresentation, error) { + // Search user by email + var kcUsers, err = c.keycloakClient.GetUsers(accessToken, c.realm, c.realm, "email", *user.EmailAddress) + if err != nil { + c.logger.Warn(ctx, "msg", "Can't get user from db", "err", err.Error()) + return nil, errorhandler.CreateInternalServerError("database") + } + if kcUsers.Count == nil || *kcUsers.Count == 0 { + // New user: go on registering + return nil, nil + } + + var kcUser kc.UserRepresentation = kcUsers.Users[0] + if kcUser.EmailVerified == nil || *kcUser.EmailVerified { + c.logger.Warn(ctx, "msg", "Attempt to register a user with email of an already validated user") + // Should not leak that email is already in use + return nil, errorhandler.CreateBadRequestError(errorhandler.MsgErrInvalidParam + ".user_emailAddress") + } + + // Free to go on processing this user creation + return &kcUser, nil +} + +func (c *component) generateUsername(chars []rune, length int) string { + var b strings.Builder + + for j := 0; j < length; j++ { + b.WriteRune(chars[rand.Intn(len(chars))]) + } + return b.String() +} diff --git a/pkg/register/component_test.go b/pkg/register/component_test.go new file mode 100644 index 00000000..d72643e1 --- /dev/null +++ b/pkg/register/component_test.go @@ -0,0 +1,235 @@ +package register + +import ( + "context" + "errors" + "net/http" + "testing" + + errorhandler "github.com/cloudtrust/common-service/errors" + log "github.com/cloudtrust/common-service/log" + apiregister "github.com/cloudtrust/keycloak-bridge/api/register" + "github.com/cloudtrust/keycloak-bridge/internal/dto" + "github.com/cloudtrust/keycloak-bridge/pkg/register/mock" + kc "github.com/cloudtrust/keycloak-client" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func createValidUser() apiregister.User { + var ( + gender = "M" + firstName = "Marc" + lastName = "El-Bichoun" + email = "marcel.bichon@elca.ch" + phoneNumber = "00 33 686 550011" + ) + + return apiregister.User{ + Gender: &gender, + FirstName: &firstName, + LastName: &lastName, + EmailAddress: &email, + PhoneNumber: &phoneNumber, + } +} + +func TestRegisterUser(t *testing.T) { + var mockCtrl = gomock.NewController(t) + defer mockCtrl.Finish() + + var mockKeycloakClient = mock.NewKeycloakClient(mockCtrl) + var mockTokenProvider = mock.NewOidcTokenProvider(mockCtrl) + var mockConfigDB = mock.NewConfigurationDBModule(mockCtrl) + var mockUsersDB = mock.NewUsersDBModule(mockCtrl) + var mockEventsDB = mock.NewEventsDBModule(mockCtrl) + + var ctx = context.TODO() + var targetRealm = "cloudtrust" + var confRealm = "test" + var validUser = createValidUser() + var accessToken = "abcdef" + var empty = 0 + var usersSearchResult = kc.UsersPageRepresentation{Count: &empty} + var component = NewComponent(targetRealm, mockKeycloakClient, mockTokenProvider, mockUsersDB, mockConfigDB, mockEventsDB, log.NewNopLogger()) + + t.Run("User is not valid", func(t *testing.T) { + // User is not valid + var _, err = component.RegisterUser(ctx, confRealm, apiregister.User{}) + assert.NotNil(t, err) + }) + + t.Run("Can't get realm configuration from DB", func(t *testing.T) { + var dbError = errors.New("db error") + mockConfigDB.EXPECT().GetConfiguration(ctx, confRealm).Return(dto.RealmConfiguration{}, dbError) + + var _, err = component.RegisterUser(ctx, confRealm, createValidUser()) + assert.Equal(t, dbError, err) + }) + + t.Run("Can't get access token", func(t *testing.T) { + var tokenError = errors.New("token error") + mockConfigDB.EXPECT().GetConfiguration(ctx, confRealm).Return(dto.RealmConfiguration{}, nil) + mockTokenProvider.EXPECT().ProvideToken(ctx).Return("", tokenError) + + var _, err = component.RegisterUser(ctx, confRealm, createValidUser()) + assert.Equal(t, tokenError, err) + }) + + t.Run("checkExistingUser fails", func(t *testing.T) { + var kcError = errors.New("kc GetUsers error") + mockConfigDB.EXPECT().GetConfiguration(ctx, confRealm).Return(dto.RealmConfiguration{}, nil) + mockTokenProvider.EXPECT().ProvideToken(ctx).Return(accessToken, nil) + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", *validUser.EmailAddress).Return(kc.UsersPageRepresentation{}, kcError) + + var _, err = component.RegisterUser(ctx, confRealm, createValidUser()) + assert.NotNil(t, err) + }) + + t.Run("Can't generate unused username", func(t *testing.T) { + mockConfigDB.EXPECT().GetConfiguration(ctx, confRealm).Return(dto.RealmConfiguration{}, nil) + mockTokenProvider.EXPECT().ProvideToken(ctx).Return(accessToken, nil) + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", *validUser.EmailAddress).Return(usersSearchResult, nil) + mockKeycloakClient.EXPECT().CreateUser(accessToken, targetRealm, targetRealm, gomock.Any()). + Return("", errorhandler.Error{Status: http.StatusConflict, Message: "keycloak.existing.username"}). + Times(10) + + var _, err = component.RegisterUser(ctx, confRealm, validUser) + assert.NotNil(t, err) + }) + + t.Run("Create user in Keycloak fails", func(t *testing.T) { + var keycloakError = errors.New("keycloak create error") + mockConfigDB.EXPECT().GetConfiguration(ctx, confRealm).Return(dto.RealmConfiguration{}, nil) + mockTokenProvider.EXPECT().ProvideToken(ctx).Return(accessToken, nil) + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", *validUser.EmailAddress).Return(usersSearchResult, nil) + mockKeycloakClient.EXPECT().CreateUser(accessToken, targetRealm, targetRealm, gomock.Any()).Return("", keycloakError) + + var _, err = component.RegisterUser(ctx, confRealm, validUser) + assert.Equal(t, keycloakError, err) + }) + + t.Run("Update user in KC fails", func(t *testing.T) { + var updateError = errors.New("update error") + var userID = "abc789def" + var one = 1 + var disabled = false + var user = kc.UserRepresentation{Id: &userID, Email: validUser.EmailAddress, EmailVerified: &disabled} + var userExistsSearch = kc.UsersPageRepresentation{ + Count: &one, + Users: []kc.UserRepresentation{user}, + } + mockConfigDB.EXPECT().GetConfiguration(ctx, confRealm).Return(dto.RealmConfiguration{}, nil) + mockTokenProvider.EXPECT().ProvideToken(ctx).Return(accessToken, nil) + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", *validUser.EmailAddress).Return(userExistsSearch, nil) + mockKeycloakClient.EXPECT().UpdateUser(accessToken, targetRealm, userID, gomock.Any()).Return(updateError) + + var _, err = component.RegisterUser(ctx, confRealm, createValidUser()) + assert.Equal(t, updateError, err) + }) + + t.Run("DB: Create or update user fails", func(t *testing.T) { + var insertError = errors.New("insert error") + var token = "abcdef" + var userID = "abc789def" + mockConfigDB.EXPECT().GetConfiguration(ctx, confRealm).Return(dto.RealmConfiguration{}, nil) + mockTokenProvider.EXPECT().ProvideToken(ctx).Return(token, nil) + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", *validUser.EmailAddress).Return(usersSearchResult, nil) + mockKeycloakClient.EXPECT().CreateUser(token, targetRealm, targetRealm, gomock.Any()).Return(userID, nil) + mockUsersDB.EXPECT().StoreOrUpdateUser(ctx, targetRealm, gomock.Any()).Return(insertError) + + var _, err = component.RegisterUser(ctx, confRealm, createValidUser()) + assert.Equal(t, insertError, err) + }) + + t.Run("No required actions. RegisterUser is successful", func(t *testing.T) { + var token = "abcdef" + var userID = "abc789def" + mockConfigDB.EXPECT().GetConfiguration(ctx, confRealm).Return(dto.RealmConfiguration{}, nil) + mockTokenProvider.EXPECT().ProvideToken(ctx).Return(token, nil) + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", *validUser.EmailAddress).Return(usersSearchResult, nil) + mockKeycloakClient.EXPECT().CreateUser(token, targetRealm, targetRealm, gomock.Any()).Return(userID, nil) + mockUsersDB.EXPECT().StoreOrUpdateUser(ctx, targetRealm, gomock.Any()).Return(nil) + mockEventsDB.EXPECT().ReportEvent(gomock.Any(), "REGISTER_USER", "back-office", gomock.Any()) + + var _, err = component.RegisterUser(ctx, confRealm, createValidUser()) + assert.Nil(t, err) + }) + + t.Run("Send execute actions mail fails", func(t *testing.T) { + var sendActionsError = errors.New("send actions error") + var token = "abcdef" + var userID = "abc789def" + var requiredActions = []string{"execute", "actions"} + var realmConfiguration = dto.RealmConfiguration{RegisterExecuteActions: &requiredActions} + mockConfigDB.EXPECT().GetConfiguration(ctx, confRealm).Return(realmConfiguration, nil) + mockTokenProvider.EXPECT().ProvideToken(ctx).Return(token, nil) + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", *validUser.EmailAddress).Return(usersSearchResult, nil) + mockKeycloakClient.EXPECT().CreateUser(token, targetRealm, targetRealm, gomock.Any()).Return(userID, nil) + mockUsersDB.EXPECT().StoreOrUpdateUser(ctx, targetRealm, gomock.Any()).Return(nil) + mockKeycloakClient.EXPECT().ExecuteActionsEmail(token, targetRealm, userID, requiredActions).Return(sendActionsError) + + var _, err = component.RegisterUser(ctx, confRealm, createValidUser()) + assert.Equal(t, sendActionsError, err) + }) +} + +func TestCheckExistingUser(t *testing.T) { + var mockCtrl = gomock.NewController(t) + defer mockCtrl.Finish() + + var mockKeycloakClient = mock.NewKeycloakClient(mockCtrl) + var mockTokenProvider = mock.NewOidcTokenProvider(mockCtrl) + var mockConfigDB = mock.NewConfigurationDBModule(mockCtrl) + var mockUsersDB = mock.NewUsersDBModule(mockCtrl) + var mockEventsDB = mock.NewEventsDBModule(mockCtrl) + + var ctx = context.TODO() + var accessToken = "123-456-789" + var targetRealm = "trustid" + var email = "user@trustid.swiss" + var userID = "ab54f9a-97bi94" + var user = apiregister.User{EmailAddress: &email} + var verified = true + var keycloakUser = kc.UserRepresentation{Id: &userID} + var empty = 0 + var one = 1 + var foundUsers = kc.UsersPageRepresentation{Count: &one, Users: []kc.UserRepresentation{keycloakUser}} + + var component = &component{targetRealm, mockKeycloakClient, mockTokenProvider, mockUsersDB, mockConfigDB, mockEventsDB, log.NewNopLogger()} + + t.Run("GetUsers fails", func(t *testing.T) { + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", email).Return(foundUsers, errors.New("GetUsers fails")) + + var _, err = component.checkExistingUser(ctx, accessToken, user) + + assert.NotNil(t, err) + }) + + t.Run("GetUsers: not found", func(t *testing.T) { + var usersNotFound = kc.UsersPageRepresentation{Count: &empty} + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", email).Return(usersNotFound, nil) + + var user, err = component.checkExistingUser(ctx, accessToken, user) + + assert.Nil(t, err) + assert.Nil(t, user) + }) + + t.Run("Keycloak GetUser fails", func(t *testing.T) { + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", email).Return(foundUsers, nil) + + var _, err = component.checkExistingUser(ctx, accessToken, user) + + assert.NotNil(t, err) + }) + + t.Run("User is validated in Keycloak", func(t *testing.T) { + foundUsers.Users[0].EmailVerified = &verified + mockKeycloakClient.EXPECT().GetUsers(accessToken, targetRealm, targetRealm, "email", email).Return(foundUsers, nil) + + var _, err = component.checkExistingUser(ctx, accessToken, user) + + assert.NotNil(t, err) + }) +} diff --git a/pkg/register/endpoint.go b/pkg/register/endpoint.go new file mode 100644 index 00000000..49bf73cc --- /dev/null +++ b/pkg/register/endpoint.go @@ -0,0 +1,33 @@ +package register + +import ( + "context" + + cs "github.com/cloudtrust/common-service" + commonerrors "github.com/cloudtrust/common-service/errors" + apiregister "github.com/cloudtrust/keycloak-bridge/api/register" + msg "github.com/cloudtrust/keycloak-bridge/internal/messages" + "github.com/go-kit/kit/endpoint" +) + +// Endpoints for self service +type Endpoints struct { + RegisterUser endpoint.Endpoint +} + +// MakeRegisterUserEndpoint endpoint creation +func MakeRegisterUserEndpoint(component Component) cs.Endpoint { + return func(ctx context.Context, req interface{}) (interface{}, error) { + var m = req.(map[string]string) + var realm = m["realm"] + if realm == "" { + return nil, commonerrors.CreateBadRequestError(commonerrors.MsgErrInvalidParam + "." + msg.Realm) + } + + var user, err = apiregister.UserFromJSON(m["body"]) + if err != nil { + return nil, commonerrors.CreateBadRequestError(commonerrors.MsgErrInvalidParam + "." + msg.BodyContent) + } + return component.RegisterUser(context.WithValue(ctx, cs.CtContextRealmID, realm), realm, user) + } +} diff --git a/pkg/register/endpoint_test.go b/pkg/register/endpoint_test.go new file mode 100644 index 00000000..96793796 --- /dev/null +++ b/pkg/register/endpoint_test.go @@ -0,0 +1,41 @@ +package register + +import ( + "context" + "encoding/json" + "testing" + + apiregister "github.com/cloudtrust/keycloak-bridge/api/register" + "github.com/cloudtrust/keycloak-bridge/pkg/register/mock" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestMakeRegisterUserEndpoint(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockRegisterComponent := mock.NewComponent(mockCtrl) + + var realm = "master" + var first = "John" + var last = "Doe" + var user = apiregister.User{FirstName: &first, LastName: &last} + var m = map[string]string{} + + { + var bytes, _ = json.Marshal(user) + m["realm"] = realm + m["body"] = string(bytes) + mockRegisterComponent.EXPECT().RegisterUser(gomock.Any(), realm, user).Return("", nil).Times(1) + _, err := MakeRegisterUserEndpoint(mockRegisterComponent)(context.Background(), m) + assert.Nil(t, err) + } + + { + m["realm"] = realm + m["body"] = "{" + _, err := MakeRegisterUserEndpoint(mockRegisterComponent)(context.Background(), m) + assert.NotNil(t, err) + } +} diff --git a/pkg/register/http.go b/pkg/register/http.go new file mode 100644 index 00000000..a706879e --- /dev/null +++ b/pkg/register/http.go @@ -0,0 +1,30 @@ +package register + +import ( + "context" + "net/http" + + commonhttp "github.com/cloudtrust/common-service/http" + "github.com/cloudtrust/common-service/log" + "github.com/go-kit/kit/endpoint" + http_transport "github.com/go-kit/kit/transport/http" +) + +const ( + // RegExpRealmName is a regular expression for realm names + RegExpRealmName = `^[a-zA-Z0-9_-]{1,36}$` +) + +// MakeRegisterHandler make an HTTP handler for the self-register endpoint. +func MakeRegisterHandler(e endpoint.Endpoint, logger log.Logger) *http_transport.Server { + pathParams := map[string]string{"realm": RegExpRealmName} + queryParams := map[string]string{"realm": RegExpRealmName} + + return http_transport.NewServer(e, + func(ctx context.Context, req *http.Request) (interface{}, error) { + return commonhttp.DecodeRequest(ctx, req, pathParams, queryParams) + }, + commonhttp.EncodeReply, + http_transport.ServerErrorEncoder(commonhttp.ErrorHandler(logger)), + ) +} diff --git a/pkg/register/http_test.go b/pkg/register/http_test.go new file mode 100644 index 00000000..7940cadc --- /dev/null +++ b/pkg/register/http_test.go @@ -0,0 +1,52 @@ +package register + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cloudtrust/common-service/log" + api "github.com/cloudtrust/keycloak-bridge/api/register" + "github.com/cloudtrust/keycloak-bridge/internal/keycloakb" + "github.com/cloudtrust/keycloak-bridge/pkg/register/mock" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" +) + +func TestHTTPRegisterHandler(t *testing.T) { + var mockCtrl = gomock.NewController(t) + defer mockCtrl.Finish() + var mockRegisterComponent = mock.NewComponent(mockCtrl) + + r := mux.NewRouter() + r.Handle("/register/realm/{realm}/user", MakeRegisterHandler(keycloakb.ToGoKitEndpoint(MakeRegisterUserEndpoint(mockRegisterComponent)), log.NewNopLogger())) + + ts := httptest.NewServer(r) + defer ts.Close() + + var first = "John" + var last = "Doe" + + { + body := api.User{ + FirstName: &first, + LastName: &last, + } + json, _ := json.Marshal(body) + + mockRegisterComponent.EXPECT().RegisterUser(gomock.Any(), "master", gomock.Any()).Return("abc", nil).Times(1) + + res, err := http.Post(ts.URL+"/register/realm/master/user", "application/json", ioutil.NopCloser(bytes.NewBuffer(json))) + + assert.Nil(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + buf := new(bytes.Buffer) + buf.ReadFrom(res.Body) + assert.Equal(t, `"abc"`, buf.String()) + } +} diff --git a/pkg/register/mock_test.go b/pkg/register/mock_test.go new file mode 100644 index 00000000..b1ac644d --- /dev/null +++ b/pkg/register/mock_test.go @@ -0,0 +1,7 @@ +package register + +//go:generate mockgen -destination=./mock/register.go -package=mock -mock_names=Component=Component,KeycloakClient=KeycloakClient,ConfigurationDBModule=ConfigurationDBModule,UsersDBModule=UsersDBModule github.com/cloudtrust/keycloak-bridge/pkg/register Component,KeycloakClient,ConfigurationDBModule,UsersDBModule +//go:generate mockgen -destination=./mock/keycloak.go -package=mock -mock_names=OidcTokenProvider=OidcTokenProvider github.com/cloudtrust/keycloak-client OidcTokenProvider +//go:generate mockgen -destination=./mock/database.go -package=mock -mock_names=EventsDBModule=EventsDBModule,Transaction=Transaction github.com/cloudtrust/common-service/database EventsDBModule,Transaction +//go:generate mockgen -destination=./mock/sqltypes.go -package=mock -mock_names=SQLRow=SQLRow github.com/cloudtrust/common-service/database/sqltypes SQLRow +//go:generate mockgen -destination=./mock/http.go -package=mock -mock_names=Handler=Handler,ResponseWriter=ResponseWriter net/http Handler,ResponseWriter diff --git a/pkg/register/module.go b/pkg/register/module.go new file mode 100644 index 00000000..096a8faa --- /dev/null +++ b/pkg/register/module.go @@ -0,0 +1,74 @@ +package register + +import ( + "context" + "database/sql" + "encoding/json" + + "github.com/cloudtrust/common-service/log" + apiregister "github.com/cloudtrust/keycloak-bridge/api/register" + + "github.com/cloudtrust/common-service/database/sqltypes" +) + +const ( + updateUserStmt = `INSERT INTO user_details (realm_id, user_id, details) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE details=?;` + selectUserStmt = ` + SELECT details + FROM user_details + WHERE realm_id=? + AND user_id=? + ` +) + +// UsersDBModule interface +type UsersDBModule interface { + StoreOrUpdateUser(ctx context.Context, realm string, user apiregister.DBUser) error + GetUser(ctx context.Context, realm string, userID string) (*apiregister.DBUser, error) +} + +type usersDBModule struct { + db sqltypes.CloudtrustDB + logger log.Logger +} + +// NewUsersDBModule returns a UsersDB module. +func NewUsersDBModule(db sqltypes.CloudtrustDB, logger log.Logger) UsersDBModule { + return &usersDBModule{ + db: db, + logger: logger, + } +} + +func (c *usersDBModule) StoreOrUpdateUser(ctx context.Context, realm string, user apiregister.DBUser) error { + // transform user object into JSON string + userJSON, err := json.Marshal(user) + if err != nil { + return err + } + + // update value in DB + _, err = c.db.Exec(updateUserStmt, realm, user.UserID, string(userJSON), string(userJSON)) + return err +} + +func (c *usersDBModule) GetUser(ctx context.Context, realm string, userID string) (*apiregister.DBUser, error) { + var detailsJSON string + var details = apiregister.DBUser{} + row := c.db.QueryRow(selectUserStmt, realm, userID) + + switch err := row.Scan(&detailsJSON); err { + case sql.ErrNoRows: + return nil, nil + default: + if err != nil { + return nil, err + } + + err = json.Unmarshal([]byte(detailsJSON), &details) + details.UserID = &userID + return &details, err + } +} diff --git a/pkg/register/module_test.go b/pkg/register/module_test.go new file mode 100644 index 00000000..68de453e --- /dev/null +++ b/pkg/register/module_test.go @@ -0,0 +1,72 @@ +package register + +import ( + "context" + "database/sql" + "errors" + "testing" + + "github.com/cloudtrust/common-service/log" + apiregister "github.com/cloudtrust/keycloak-bridge/api/register" + "github.com/cloudtrust/keycloak-bridge/internal/keycloakb/mock" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestStoreOrUpdateUser(t *testing.T) { + var mockCtrl = gomock.NewController(t) + defer mockCtrl.Finish() + var mockDB = mock.NewCloudtrustDB(mockCtrl) + + var userID = "123789" + mockDB.EXPECT().Exec(gomock.Any(), "realmId", &userID, gomock.Any(), gomock.Any()).Return(nil, nil).Times(1) + var configDBModule = NewUsersDBModule(mockDB, log.NewNopLogger()) + var err = configDBModule.StoreOrUpdateUser(context.Background(), "realmId", apiregister.DBUser{UserID: &userID}) + assert.Nil(t, err) +} + +func TestGetUser(t *testing.T) { + var mockCtrl = gomock.NewController(t) + defer mockCtrl.Finish() + + var mockDB = mock.NewCloudtrustDB(mockCtrl) + var mockSQLRow = mock.NewSQLRow(mockCtrl) + + var realm = "my-realm" + var userID = "user-id" + var ctx = context.TODO() + + t.Run("Select: unexpected error", func(t *testing.T) { + var unexpectedError = errors.New("unexpected") + mockDB.EXPECT().QueryRow(gomock.Any(), realm, userID).Return(mockSQLRow) + mockSQLRow.EXPECT().Scan(gomock.Any()).Return(unexpectedError) + + var configDBModule = NewUsersDBModule(mockDB, log.NewNopLogger()) + var _, err = configDBModule.GetUser(ctx, realm, userID) + assert.Equal(t, unexpectedError, err) + }) + + t.Run("Select: NOT FOUND", func(t *testing.T) { + mockDB.EXPECT().QueryRow(gomock.Any(), realm, userID).Return(mockSQLRow) + mockSQLRow.EXPECT().Scan(gomock.Any()).Return(sql.ErrNoRows) + + var configDBModule = NewUsersDBModule(mockDB, log.NewNopLogger()) + var user, err = configDBModule.GetUser(ctx, realm, userID) + assert.Nil(t, err) + assert.Nil(t, user) + }) + + t.Run("Select successful", func(t *testing.T) { + mockDB.EXPECT().QueryRow(gomock.Any(), realm, userID).Return(mockSQLRow) + mockSQLRow.EXPECT().Scan(gomock.Any()).DoAndReturn(func(dest ...interface{}) error { + var ptr = dest[0].(*string) + *ptr = `{"birth_location": "Antananarivo"}` + return nil + }) + + var configDBModule = NewUsersDBModule(mockDB, log.NewNopLogger()) + var user, err = configDBModule.GetUser(ctx, realm, userID) + assert.Nil(t, err) + assert.Equal(t, "Antananarivo", *user.BirthLocation) + }) +}