diff --git a/README.md b/README.md index a583314..bed7018 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Ferrum +# Ferrum . Ferrum is a **better** Authorization Server, this is a Community version. @@ -6,7 +6,7 @@ Ferrum is a **better** Authorization Server, this is a Community version. ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/wissance/Ferrum?style=plastic) ![GitHub issues](https://img.shields.io/github/issues/wissance/Ferrum?style=plastic) ![GitHub Release Date](https://img.shields.io/github/release-date/wissance/Ferrum) -![GitHub release (latest by date)](https://img.shields.io/github/downloads/wissance/Ferrum/v0.9.1/total?style=plastic) +![GitHub release (latest by date)](https://img.shields.io/github/downloads/wissance/Ferrum/v0.9.2/total?style=plastic) ![Ferrum: A better Auth Server](/img/ferrum_cover.png) @@ -43,6 +43,8 @@ it has `endpoints` SIMILAR to `Keycloak`, at present time we are having followin ## 3. How to use +`Ferrum` is thoroughly developing with maximal quality of code and solution; we are working using a `git-flow` approach; even `master` branch is a stable release branch, but `develop` is also highly stable, therefore develop version could also be used in a production. + ### 3.1 Build First of all build is simple run `go build` from application root directory. Additionally it is possible @@ -193,7 +195,68 @@ Since version `0.9.1` it is possible to use `CLI Admin` [See](api/admin/cli/READ ![Use CLI Admin from docker](/img/additional/cli_from_docker.png) -## 6. Contributors +## 6. Changes + +Brief info about changes in releases. + +### 6.1 Changes in 0.0.1 + +Features: +* `Keycloak` compatible HTTP-endpoints to issue a new `token` and to get `userinfo` + +### 6.2 Changes in 0.1.0 + +Features: +* documentation (`readme.md` file) +* integration tests + +### 6.3 Changes in 0.1.1 + +Features: +* fixed modules names + +### 6.4 Changes in 0.1.2 + +Features: +* changed module names to make it available to embed `Ferrum` in an other applications + +### 6.5 Changes in 0.1.3 + +Features: +* `Keycloak` compatible HTTP-endpoint for token introspect + +### 6.6 Changes in 0.1.4 + +Features: +* removed `/` therefore it is possible to interact with `Ferrum` using `go-cloak` package + +### 6.7 Changes in 0.9.0 + +Features +* logging +* implemented token refresh +* better docs + +### 6.8 Changes in 0.9.1 + +Features: +* `docker` && `docker-compose` for app running +* admin `CLI` `API` +* `Redis` as a production data storage + +### 6.9 Changes in 0.9.2 + +Features: +* admin cli added to docker +* test on `Redis` data manger +* used different config to run locally and in docker +* newer `Keycloak` versions support +* checked stability if `Redis` is down, `Ferrum` does not crushes and wait until `Redis` is ready +* `swagger` (`-devmode` option in cmd line) and `Keycloak` compatible HTTP endpoint `openid-configuration` +* support for federated user (without full providers impl, just preliminary) +* store password as a hashes + +## 7. Contributors diff --git a/api/admin/cli/main.go b/api/admin/cli/main.go index 897387c..87b4995 100644 --- a/api/admin/cli/main.go +++ b/api/admin/cli/main.go @@ -6,9 +6,10 @@ import ( "encoding/json" "flag" "fmt" - "github.com/wissance/Ferrum/managers" "log" + "github.com/wissance/Ferrum/managers" + "github.com/wissance/Ferrum/api/admin/cli/operations" "github.com/wissance/Ferrum/config" "github.com/wissance/Ferrum/data" @@ -19,12 +20,12 @@ import ( const defaultConfig = "./config_w_redis.json" var ( - argConfigFile = flag.String("config", defaultConfig, "") - argOperation = flag.String("operation", "", "") - argResource = flag.String("resource", "", "") - argResourceId = flag.String("resource_id", "", "") - argParams = flag.String("params", "", "This is the name of the realm for operations on client or user resources") - argValue = flag.String("value", "", "Json object") + argConfigFile = flag.String("config", defaultConfig, "Application config for working with a persistent data store") + argOperation = flag.String("operation", "", "One of the available operations read|create|update|delete or user specific change/reset password") + argResource = flag.String("resource", "", "\"realm\", \"client\" or \"user\" or maybe other in future") + argResourceId = flag.String("resource_id", "", "resource object identifier, id required for the update|delete or read operation") + argParams = flag.String("params", "", "Name of a realm for operations on client or user resources") + argValue = flag.String("value", "", "Json encoded resource itself") ) func main() { @@ -121,7 +122,11 @@ func main() { if err := json.Unmarshal(value, &userNew); err != nil { log.Fatalf("json.Unmarshal failed: %s", err) } - user := data.CreateUser(userNew) + realm, err := manager.GetRealm(params) + if err != nil { + log.Fatalf("GetRealm failed: %s", err) + } + user := data.CreateUser(userNew, realm.Encoder) if err := manager.CreateUser(params, user); err != nil { log.Fatalf("CreateUser failed: %s", err) } @@ -202,7 +207,7 @@ func main() { if err := json.Unmarshal(value, &newUser); err != nil { log.Fatalf("json.Unmarshal failed: %s", err) } - user := data.CreateUser(newUser) + user := data.CreateUser(newUser, nil) if err := manager.UpdateUser(params, resourceId, user); err != nil { log.Fatalf("UpdateUser failed: %s", err) } @@ -242,7 +247,7 @@ func main() { } // TODO(SIA) Moving password verification to another location if len(value) < 8 { - log.Fatalf("Password length must be greater than 8") + log.Fatalf("Password length must be greater than 7") } password := string(value) passwordManager := manager.(PasswordManager) diff --git a/api/routing_fuzzing_test.go b/api/routing_fuzzing_test.go index fe333ca..280e31c 100644 --- a/api/routing_fuzzing_test.go +++ b/api/routing_fuzzing_test.go @@ -14,6 +14,7 @@ import ( "github.com/wissance/Ferrum/config" "github.com/wissance/Ferrum/data" "github.com/wissance/Ferrum/dto" + "github.com/wissance/Ferrum/utils/encoding" sf "github.com/wissance/stringFormatter" "github.com/stretchr/testify/assert" @@ -29,8 +30,11 @@ const ( ) var ( - testKey = []byte("qwerty1234567890") - testServerData = data.ServerData{ + testSalt = "salt" + encoder = encoding.NewPasswordJsonEncoder(testSalt) + testHashedPassowrd = encoder.GetB64PasswordHash("1234567890") + testKey = []byte("qwerty1234567890") + testServerData = data.ServerData{ Realms: []data.Realm{ { Name: testRealm1, TokenExpiration: testAccessTokenExpiration, RefreshTokenExpiration: testRefreshTokenExpiration, @@ -39,16 +43,18 @@ var ( Type: data.ClientIdAndSecrets, Value: testClient1Secret, }}, - }, Users: []interface{}{ + }, + Users: []interface{}{ map[string]interface{}{ "info": map[string]interface{}{ "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", "name": "vano", "preferred_username": "vano", "given_name": "vano ivanov", "family_name": "ivanov", "email_verified": true, }, - "credentials": map[string]interface{}{"password": "1234567890"}, + "credentials": map[string]interface{}{"password": testHashedPassowrd}, }, }, + PasswordSalt: testSalt, }, }, } diff --git a/application/application_test.go b/application/application_test.go index 7faec27..6a02e82 100644 --- a/application/application_test.go +++ b/application/application_test.go @@ -17,6 +17,7 @@ import ( "github.com/wissance/Ferrum/data" "github.com/wissance/Ferrum/dto" "github.com/wissance/Ferrum/errors" + "github.com/wissance/Ferrum/utils/encoding" "github.com/wissance/stringFormatter" ) @@ -29,8 +30,11 @@ const ( ) var ( - testKey = []byte("qwerty1234567890") - testServerData = data.ServerData{ + testSalt = "salt" + encoder = encoding.NewPasswordJsonEncoder(testSalt) + testHashedPassword = encoder.GetB64PasswordHash("1234567890") + testKey = []byte("qwerty1234567890") + testServerData = data.ServerData{ Realms: []data.Realm{ { Name: testRealm1, TokenExpiration: testAccessTokenExpiration, RefreshTokenExpiration: testRefreshTokenExpiration, @@ -39,16 +43,18 @@ var ( Type: data.ClientIdAndSecrets, Value: testClient1Secret, }}, - }, Users: []interface{}{ + }, + Users: []interface{}{ map[string]interface{}{ "info": map[string]interface{}{ "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", "name": "vano", "preferred_username": "vano", "given_name": "vano ivanov", "family_name": "ivanov", "email_verified": true, }, - "credentials": map[string]interface{}{"password": "1234567890"}, + "credentials": map[string]interface{}{"password": testHashedPassword}, }, }, + PasswordSalt: testSalt, }, }, } diff --git a/data.json b/data.json index eb262a6..0d9c4dc 100644 --- a/data.json +++ b/data.json @@ -4,6 +4,7 @@ "name": "myapp", "token_expiration": 330, "refresh_expiration": 200, + "password_salt": "1234567890", "clients": [ { "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5a14", @@ -29,7 +30,7 @@ "family_name": "sys" }, "credentials": { - "password": "1s2d3f4g90xs" + "password": "AcMWCBu5AQDN8IvSRExUUSQq7H3RH6IzsxZJqyIoEmPFtJwGknUUvzet0vhS95hgkrKLNM66v0mUB5xji8zdqA==" } }, { diff --git a/data/keycloak_user.go b/data/keycloak_user.go index 6f57de7..78d3935 100644 --- a/data/keycloak_user.go +++ b/data/keycloak_user.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" "github.com/ohler55/ojg/jp" + "github.com/wissance/Ferrum/utils/encoding" ) const ( @@ -25,9 +26,14 @@ type KeyCloakUser struct { * - rawData - any json * Return: instance of User as KeyCloakUser */ -func CreateUser(rawData interface{}) User { +func CreateUser(rawData interface{}, encoder *encoding.PasswordJsonEncoder) User { jsonData, _ := json.Marshal(&rawData) kcUser := &KeyCloakUser{rawData: rawData, jsonRawData: string(jsonData)} + password := getPathStringValue[string](kcUser.rawData, pathToPassword) + if encoder != nil { + // todo(UMV): handle CreateUser errors in the future + _ = kcUser.SetPassword(password, encoder) + } user := User(kcUser) return user } @@ -42,25 +48,29 @@ func (user *KeyCloakUser) GetUsername() string { return getPathStringValue[string](user.rawData, "info.preferred_username") } -// GetPassword returns password -/* this function use internal map to navigate over credentials.password keys to retrieve a password +// GetPasswordHash returns hash of password +/* this function use internal map to navigate over credentials.password keys to retrieve a hash of password * Parameters: no - * Returns: password + * Returns: hash of password */ -// todo(UMV): this function should be changed to GetPasswordHash also we should consider case when User is External -func (user *KeyCloakUser) GetPassword() string { - return getPathStringValue[string](user.rawData, pathToPassword) +// todo(UMV): we should consider case when User is External +func (user *KeyCloakUser) GetPasswordHash() string { + password := getPathStringValue[string](user.rawData, pathToPassword) + return password } -func (user *KeyCloakUser) SetPassword(password string) error { - mask, err := jp.ParseString(pathToPassword) - if err != nil { - return fmt.Errorf("jp.ParseString failed: %w", err) - } - if err := mask.Set(user.rawData, password); err != nil { - return fmt.Errorf("jp.Set failed: %w", err) +// SetPassword +/* this function changes a raw password to its hash in the user's rawData and jsonRawData and sets it + * Parameters: + * - password - new password + * - encoder - encoder object with salt and hasher + */ +func (user *KeyCloakUser) SetPassword(password string, encoder *encoding.PasswordJsonEncoder) error { + hashed := encoder.GetB64PasswordHash(password) + if err := setPathStringValue(user.rawData, pathToPassword, hashed); err != nil { + return err } - jsonData, _ := json.Marshal(user.rawData) + jsonData, _ := json.Marshal(&user.rawData) user.jsonRawData = string(jsonData) return nil } @@ -131,3 +141,21 @@ func getPathStringValue[T any](rawData interface{}, path string) T { } return result } + +// setPathStringValue is a function to search data by path and set data by key, key represents as a jsonpath navigation property +/* this function uses json path to navigate over nested maps and set data + * Parameters: + * - rawData - json object + * - path - json path to retrieve part of json + * - value - value to be set to rawData + */ +func setPathStringValue(rawData interface{}, path string, value string) error { + mask, err := jp.ParseString(path) + if err != nil { + return fmt.Errorf("jp.ParseString failed: %w", err) + } + if err := mask.Set(rawData, value); err != nil { + return fmt.Errorf("jp.Set failed: %w", err) + } + return nil +} diff --git a/data/keycloak_user_test.go b/data/keycloak_user_test.go index 18f9055..510a121 100644 --- a/data/keycloak_user_test.go +++ b/data/keycloak_user_test.go @@ -2,9 +2,11 @@ package data import ( "encoding/json" + "testing" + "github.com/stretchr/testify/assert" + "github.com/wissance/Ferrum/utils/encoding" sf "github.com/wissance/stringFormatter" - "testing" ) func TestInitUserWithJsonAndCheck(t *testing.T) { @@ -16,12 +18,18 @@ func TestInitUserWithJsonAndCheck(t *testing.T) { userTemplate string federationId string }{ - {name: "simple_user", userName: "admin", preferredUsername: "Administrator", isFederated: false, - userTemplate: `{"info":{"name":"{0}", "preferred_username": "{1}"}}`}, - {name: "federated_user", userName: `m.ushakov`, preferredUsername: "m.ushakov", isFederated: true, federationId: "Wissance_test_domain", - userTemplate: `{"info":{"name":"{0}", "preferred_username": "{1}"}, "federation":{"name":"Wissance_test_domain"}}`}, - {name: "federated_user", userName: `root`, preferredUsername: "root", isFederated: false, - userTemplate: `{"info":{"name":"{0}", "preferred_username": "{1}"}, "federation":{"cfg":{}}}`}, + { + name: "simple_user", userName: "admin", preferredUsername: "Administrator", isFederated: false, + userTemplate: `{"info":{"name":"{0}", "preferred_username": "{1}"}}`, + }, + { + name: "federated_user", userName: `m.ushakov`, preferredUsername: "m.ushakov", isFederated: true, federationId: "Wissance_test_domain", + userTemplate: `{"info":{"name":"{0}", "preferred_username": "{1}"}, "federation":{"name":"Wissance_test_domain"}}`, + }, + { + name: "federated_user", userName: `root`, preferredUsername: "root", isFederated: false, + userTemplate: `{"info":{"name":"{0}", "preferred_username": "{1}"}, "federation":{"cfg":{}}}`, + }, } for _, tCase := range testCases { @@ -31,7 +39,8 @@ func TestInitUserWithJsonAndCheck(t *testing.T) { var rawUserData interface{} err := json.Unmarshal([]byte(jsonStr), &rawUserData) assert.NoError(t, err) - user := CreateUser(rawUserData) + encoder := encoding.NewPasswordJsonEncoder("salt") + user := CreateUser(rawUserData, encoder) assert.Equal(t, tCase.preferredUsername, user.GetUsername()) assert.Equal(t, tCase.isFederated, user.IsFederatedUser()) if user.IsFederatedUser() { diff --git a/data/realm.go b/data/realm.go index 71721ff..cea2012 100644 --- a/data/realm.go +++ b/data/realm.go @@ -1,5 +1,7 @@ package data +import "github.com/wissance/Ferrum/utils/encoding" + // Realm is a struct that describes typical Realm /* It was originally designed to efficiently work in memory with small amount of data therefore it contains relations with Clients and Users * But in a systems with thousands of users working at the same time it is too expensive to fetch Realm with all relations therefore @@ -12,4 +14,6 @@ type Realm struct { TokenExpiration int `json:"token_expiration"` RefreshTokenExpiration int `json:"refresh_expiration"` UserFederationServices []UserFederationServiceConfig `json:"user_federation_services"` + PasswordSalt string `json:"password_salt"` + Encoder *encoding.PasswordJsonEncoder } diff --git a/data/user.go b/data/user.go index 228ee93..26ba1ca 100644 --- a/data/user.go +++ b/data/user.go @@ -1,13 +1,16 @@ package data -import "github.com/google/uuid" +import ( + "github.com/google/uuid" + "github.com/wissance/Ferrum/utils/encoding" +) // User is a common user interface with all Required methods to get information about user, in future we probably won't have GetPassword method // because Password is not an only method for authentication type User interface { GetUsername() string - GetPassword() string - SetPassword(password string) error + GetPasswordHash() string + SetPassword(password string, encoder *encoding.PasswordJsonEncoder) error GetId() uuid.UUID GetUserInfo() interface{} GetRawData() interface{} diff --git a/managers/files/manager.go b/managers/files/manager.go index e28fc58..3999d50 100644 --- a/managers/files/manager.go +++ b/managers/files/manager.go @@ -2,9 +2,11 @@ package files import ( "encoding/json" - "github.com/wissance/Ferrum/config" "os" + "github.com/wissance/Ferrum/config" + "github.com/wissance/Ferrum/utils/encoding" + "github.com/wissance/Ferrum/errors" "github.com/google/uuid" @@ -77,6 +79,7 @@ func (mn *FileDataManager) GetRealm(realmName string) (*data.Realm, error) { // case-sensitive comparison, myapp and MyApP are different realms if e.Name == realmName { e.Users = nil + e.Encoder = encoding.NewPasswordJsonEncoder(e.PasswordSalt) return &e, nil } } @@ -101,7 +104,7 @@ func (mn *FileDataManager) GetUsers(realmName string) ([]data.User, error) { } users := make([]data.User, len(e.Users)) for i, u := range e.Users { - user := data.CreateUser(u) + user := data.CreateUser(u, nil) users[i] = user } return users, nil diff --git a/managers/files/manager_test.go b/managers/files/manager_test.go index aa2fa49..ebb077f 100644 --- a/managers/files/manager_test.go +++ b/managers/files/manager_test.go @@ -2,13 +2,14 @@ package files import ( "encoding/json" + "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wissance/Ferrum/config" "github.com/wissance/Ferrum/data" "github.com/wissance/Ferrum/logging" - "testing" ) const testDataFile = "test_data.json" @@ -67,7 +68,7 @@ func TestGetUserSuccessfully(t *testing.T) { var rawUser interface{} err := json.Unmarshal([]byte(userJson), &rawUser) assert.NoError(t, err) - expectedUser := data.CreateUser(rawUser) + expectedUser := data.CreateUser(rawUser, nil) user, err := manager.GetUser(realm, userName) assert.NoError(t, err) checkUser(t, &expectedUser, &user) @@ -96,7 +97,7 @@ func TestGetUserByIdSuccessfully(t *testing.T) { var rawUser interface{} err := json.Unmarshal([]byte(userJson), &rawUser) assert.NoError(t, err) - expectedUser := data.CreateUser(rawUser) + expectedUser := data.CreateUser(rawUser, nil) user, err := manager.GetUserById(realm, userId) assert.NoError(t, err) checkUser(t, &expectedUser, &user) @@ -160,5 +161,5 @@ func checkUsers(t *testing.T, expected *[]data.User, actual *[]data.User) { func checkUser(t *testing.T, expected *data.User, actual *data.User) { assert.Equal(t, (*expected).GetId(), (*actual).GetId()) assert.Equal(t, (*expected).GetUsername(), (*actual).GetUsername()) - assert.Equal(t, (*expected).GetPassword(), (*actual).GetPassword()) + assert.Equal(t, (*expected).GetPasswordHash(), (*actual).GetPasswordHash()) } diff --git a/managers/files/test_data.json b/managers/files/test_data.json index eb262a6..dda6580 100644 --- a/managers/files/test_data.json +++ b/managers/files/test_data.json @@ -49,7 +49,8 @@ "password": "qwerty_user" } } - ] + ], + "password_salt": "super_strong_salt" } ] } diff --git a/managers/redis/manager_realm_operations.go b/managers/redis/manager_realm_operations.go index dce169c..86773ef 100755 --- a/managers/redis/manager_realm_operations.go +++ b/managers/redis/manager_realm_operations.go @@ -7,6 +7,7 @@ import ( "github.com/wissance/Ferrum/config" "github.com/wissance/Ferrum/data" appErrs "github.com/wissance/Ferrum/errors" + "github.com/wissance/Ferrum/utils/encoding" sf "github.com/wissance/stringFormatter" ) @@ -47,6 +48,7 @@ func (mn *RedisDataManager) GetRealm(realmName string) (*data.Realm, error) { } } realm.UserFederationServices = configs + realm.Encoder = encoding.NewPasswordJsonEncoder(realm.PasswordSalt) return realm, nil } @@ -69,7 +71,7 @@ func (mn *RedisDataManager) CreateRealm(newRealm data.Realm) error { } // TODO(SIA) Add transaction // TODO(SIA) use function isExists - _, err := mn.getRealmObject(newRealm.Name) + _, err := mn.GetRealm(newRealm.Name) if err == nil { return appErrs.NewObjectExistsError(string(Realm), newRealm.Name, "") } @@ -98,10 +100,13 @@ func (mn *RedisDataManager) CreateRealm(newRealm data.Realm) error { } } + salt := encoding.GenerateRandomSalt() + if len(newRealm.Users) != 0 { realmUsers := make([]data.ExtendedIdentifier, len(newRealm.Users)) + encoder := encoding.NewPasswordJsonEncoder(salt) for i, user := range newRealm.Users { - newUser := data.CreateUser(user) + newUser := data.CreateUser(user, encoder) newUserName := newUser.GetUsername() if upsertUserErr := mn.upsertUserObject(newRealm.Name, newUserName, newUser.GetJsonString()); upsertUserErr != nil { return appErrs.NewUnknownError("upsertUserObject", "RedisDataManager.CreateRealm", upsertUserErr) @@ -123,6 +128,8 @@ func (mn *RedisDataManager) CreateRealm(newRealm data.Realm) error { Users: []any{}, TokenExpiration: newRealm.TokenExpiration, RefreshTokenExpiration: newRealm.RefreshTokenExpiration, + PasswordSalt: salt, + Encoder: nil, } jsonShortRealm, err := json.Marshal(shortRealm) if err != nil { diff --git a/managers/redis/manager_test.go b/managers/redis/manager_test.go index 73f3f60..838a79f 100644 --- a/managers/redis/manager_test.go +++ b/managers/redis/manager_test.go @@ -12,6 +12,7 @@ import ( "github.com/wissance/Ferrum/data" appErrs "github.com/wissance/Ferrum/errors" "github.com/wissance/Ferrum/logging" + "github.com/wissance/Ferrum/utils/encoding" sf "github.com/wissance/stringFormatter" ) @@ -76,7 +77,7 @@ func TestCreateRealmSuccessfully(t *testing.T) { expectedUsers := make([]data.User, len(realm.Users)) if len(realm.Users) > 0 { for i := range realm.Users { - expectedUsers[i] = data.CreateUser(realm.Users[i]) + expectedUsers[i] = data.CreateUser(realm.Users[i], realm.Encoder) } } checkUsers(t, &expectedUsers, &users) @@ -461,12 +462,14 @@ func TestGetClientFailsNonExistingClient(t *testing.T) { func TestGetUsersSuccessfully(t *testing.T) { // 1. Create Realm manager := createTestRedisDataManager(t) - realm := data.Realm{ + r := data.Realm{ Name: sf.Format("realm_4_get_multiple_users_{0}", uuid.New().String()), TokenExpiration: 3600, RefreshTokenExpiration: 1800, } - err := manager.CreateRealm(realm) + err := manager.CreateRealm(r) + assert.NoError(t, err) + realm, err := manager.GetRealm(r.Name) assert.NoError(t, err) // 2. Create multiple users users := make([]data.User, 3) @@ -478,9 +481,9 @@ func TestGetUsersSuccessfully(t *testing.T) { var rawUser interface{} err = json.Unmarshal([]byte(jsonStr), &rawUser) assert.NoError(t, err) - user := data.CreateUser(rawUser) - users[i] = user + user := data.CreateUser(rawUser, nil) err = manager.CreateUser(realm.Name, user) + users[i] = user assert.NoError(t, err) } // 3. Get all related to realm users @@ -538,7 +541,7 @@ func TestGetUserByIdSuccessfully(t *testing.T) { var rawUser interface{} err = json.Unmarshal([]byte(jsonStr), &rawUser) assert.NoError(t, err) - user := data.CreateUser(rawUser) + user := data.CreateUser(rawUser, nil) err = manager.CreateUser(realm.Name, user) assert.NoError(t, err) @@ -601,7 +604,7 @@ func TestCreateUserSuccessfully(t *testing.T) { var rawUser interface{} err = json.Unmarshal([]byte(jsonStr), &rawUser) assert.NoError(t, err) - user := data.CreateUser(rawUser) + user := data.CreateUser(rawUser, r.Encoder) err = manager.CreateUser(realm.Name, user) assert.NoError(t, err) storedUser, err := manager.GetUser(realm.Name, tCase.userName) @@ -620,6 +623,7 @@ func TestCreateUserFailsDuplicateUser(t *testing.T) { Name: "realm_4_test_user_create_fails_duplicate", TokenExpiration: 3600, RefreshTokenExpiration: 1800, + Encoder: encoding.NewPasswordJsonEncoder("salt"), } err := manager.CreateRealm(realm) assert.NoError(t, err) @@ -629,7 +633,7 @@ func TestCreateUserFailsDuplicateUser(t *testing.T) { var rawUser interface{} err = json.Unmarshal([]byte(jsonStr), &rawUser) assert.NoError(t, err) - user := data.CreateUser(rawUser) + user := data.CreateUser(rawUser, realm.Encoder) err = manager.CreateUser(realm.Name, user) assert.NoError(t, err) @@ -648,6 +652,7 @@ func TestUpdateUserSuccessfully(t *testing.T) { Name: "realm_4_test_user_update", TokenExpiration: 3600, RefreshTokenExpiration: 1800, + Encoder: encoding.NewPasswordJsonEncoder("salt"), } err := manager.CreateRealm(realm) assert.NoError(t, err) @@ -658,7 +663,7 @@ func TestUpdateUserSuccessfully(t *testing.T) { var rawUser interface{} err = json.Unmarshal([]byte(jsonStr), &rawUser) assert.NoError(t, err) - user := data.CreateUser(rawUser) + user := data.CreateUser(rawUser, realm.Encoder) err = manager.CreateUser(realm.Name, user) assert.NoError(t, err) @@ -666,7 +671,7 @@ func TestUpdateUserSuccessfully(t *testing.T) { jsonStr = sf.Format(jsonTemplate, "pppetrov", "67890", "00000000-0000-0000-0000-000000000001") err = json.Unmarshal([]byte(jsonStr), &rawUser) assert.NoError(t, err) - user = data.CreateUser(rawUser) + user = data.CreateUser(rawUser, nil) err = manager.UpdateUser(realm.Name, userName, user) assert.NoError(t, err) @@ -695,7 +700,7 @@ func TestUpdateUserFailsNonExistingUser(t *testing.T) { var rawUser interface{} err = json.Unmarshal([]byte(jsonStr), &rawUser) assert.NoError(t, err) - user := data.CreateUser(rawUser) + user := data.CreateUser(rawUser, nil) err = manager.UpdateUser(realm.Name, userName, user) assert.Error(t, err) assert.True(t, errors.As(err, &appErrs.EmptyNotFoundErr)) @@ -721,7 +726,7 @@ func TestDeleteUserSuccessfully(t *testing.T) { var rawUser interface{} err = json.Unmarshal([]byte(jsonStr), &rawUser) assert.NoError(t, err) - user := data.CreateUser(rawUser) + user := data.CreateUser(rawUser, nil) err = manager.CreateUser(realm.Name, user) assert.NoError(t, err) u, err := manager.GetUser(realm.Name, userName) @@ -777,7 +782,7 @@ func TestGetUserFailsNonExistingUser(t *testing.T) { func TestChangeUserPasswordSuccessfully(t *testing.T) { manager := createTestRedisDataManager(t) // 1. Create Realm+Client+User - realm := data.Realm{ + realm := &data.Realm{ Name: sf.Format("app_4_user_pwd_change_check_{0}", uuid.New().String()), TokenExpiration: 3600, RefreshTokenExpiration: 1800, @@ -792,19 +797,21 @@ func TestChangeUserPasswordSuccessfully(t *testing.T) { Value: uuid.New().String(), }, } - realm.Clients = append([]data.Client{client}) + realm.Clients = append(realm.Clients, client) + err := manager.CreateRealm(*realm) + assert.NoError(t, err) + + realm, err = manager.GetRealm(realm.Name) + assert.NoError(t, err) userName := "new_app_user" userTemplate := `{"info":{"preferred_username":"{0}"}, "credentials":{"password": "{1}"}}` userJson := sf.Format(userTemplate, userName, "123") var rawUser interface{} - err := json.Unmarshal([]byte(userJson), &rawUser) - assert.NoError(t, err) - realm.Users = append([]interface{}{rawUser}) - - err = manager.CreateRealm(realm) + err = json.Unmarshal([]byte(userJson), &rawUser) assert.NoError(t, err) - _, err = manager.GetRealm(realm.Name) + user := data.CreateUser(rawUser, realm.Encoder) + err = manager.CreateUser(realm.Name, user) assert.NoError(t, err) // 2. Reset Password and check ... @@ -812,10 +819,12 @@ func TestChangeUserPasswordSuccessfully(t *testing.T) { err = manager.SetPassword(realm.Name, userName, newPassword) assert.NoError(t, err) + var rawUser2 interface{} userJson = sf.Format(userTemplate, userName, newPassword) - err = json.Unmarshal([]byte(userJson), &rawUser) + err = json.Unmarshal([]byte(userJson), &rawUser2) + assert.NoError(t, err) + expectedUser := data.CreateUser(rawUser2, realm.Encoder) assert.NoError(t, err) - expectedUser := data.CreateUser(rawUser) u, err := manager.GetUser(realm.Name, userName) assert.NoError(t, err) checkUser(t, &expectedUser, &u) @@ -1057,7 +1066,7 @@ func checkUsers(t *testing.T, expected *[]data.User, actual *[]data.User) { func checkUser(t *testing.T, expected *data.User, actual *data.User) { assert.Equal(t, (*expected).GetId(), (*actual).GetId()) assert.Equal(t, (*expected).GetUsername(), (*actual).GetUsername()) - assert.Equal(t, (*expected).GetPassword(), (*actual).GetPassword()) + assert.Equal(t, (*expected).GetPasswordHash(), (*actual).GetPasswordHash()) } func checkUserFederationConfigs(t *testing.T, expected *[]data.UserFederationServiceConfig, actual *[]data.UserFederationServiceConfig) { diff --git a/managers/redis/manager_user_operations.go b/managers/redis/manager_user_operations.go index d3fec05..ac4dbbd 100644 --- a/managers/redis/manager_user_operations.go +++ b/managers/redis/manager_user_operations.go @@ -3,6 +3,7 @@ package redis import ( "encoding/json" "errors" + "github.com/google/uuid" "github.com/wissance/Ferrum/config" "github.com/wissance/Ferrum/data" @@ -57,7 +58,7 @@ func (mn *RedisDataManager) GetUsers(realmName string) ([]data.User, error) { userData := make([]data.User, len(realmUsersData)) for i, u := range realmUsersData { - userData[i] = data.CreateUser(u) + userData[i] = data.CreateUser(u, nil) } return userData, nil } @@ -82,7 +83,7 @@ func (mn *RedisDataManager) GetUser(realmName string, userName string) (data.Use } return nil, errors2.NewUnknownError("getSingleRedisObject", "RedisDataManager.GetUser", err) } - user := data.CreateUser(*rawUser) + user := data.CreateUser(*rawUser, nil) return user, nil } @@ -128,7 +129,7 @@ func (mn *RedisDataManager) CreateUser(realmName string, userNew data.User) erro } // TODO(SIA) Add transaction // TODO(SIA) use function isExists - _, err := mn.getRealmObject(realmName) + _, err := mn.GetRealm(realmName) if err != nil { mn.logger.Warn(sf.Format("CreateUser: GetRealmObject failed, error: {0}", err.Error())) return err @@ -143,7 +144,6 @@ func (mn *RedisDataManager) CreateUser(realmName string, userNew data.User) erro mn.logger.Warn(sf.Format("CreateUser: GetUser failed, error: {0}", err.Error())) return err } - upsertUserErr := mn.upsertUserObject(realmName, userName, userNew.GetJsonString()) if upsertUserErr != nil { mn.logger.Error(sf.Format("CreateUser: addUserToRealm failed, error: {0}", upsertUserErr.Error())) @@ -241,7 +241,11 @@ func (mn *RedisDataManager) SetPassword(realmName string, userName string, passw if err != nil { return errors2.NewUnknownError("GetUser", "RedisDataManager.SetPassword", err) } - if setPasswordErr := user.SetPassword(password); setPasswordErr != nil { + realm, err := mn.GetRealm(realmName) + if err != nil { + return errors2.NewUnknownError("GetRealm", "RedisDataManager.SetPassword", err) + } + if setPasswordErr := user.SetPassword(password, realm.Encoder); setPasswordErr != nil { return errors2.NewUnknownError("SetPassword", "RedisDataManager.SetPassword", setPasswordErr) } if upsertUserErr := mn.upsertUserObject(realmName, userName, user.GetJsonString()); upsertUserErr != nil { diff --git a/services/token_based_security.go b/services/token_based_security.go index ef4118d..a9bf1e1 100644 --- a/services/token_based_security.go +++ b/services/token_based_security.go @@ -1,9 +1,10 @@ package services import ( - sf "github.com/wissance/stringFormatter" "time" + sf "github.com/wissance/stringFormatter" + "github.com/google/uuid" "github.com/wissance/Ferrum/data" "github.com/wissance/Ferrum/dto" @@ -73,15 +74,20 @@ func (service *TokenBasedSecurityService) CheckCredentials(tokenIssueData *dto.T return &data.OperationError{Msg: errors.InvalidUserCredentialsMsg, Description: errors.InvalidUserCredentialsDesc} } + realm, err := (*service.DataProvider).GetRealm(realmName) + if err != nil { + service.logger.Trace("Credential check: failed to get realm") + return &data.OperationError{Msg: "failed to get realm", Description: err.Error()} + } + if user.IsFederatedUser() { msg := sf.Format("User \"{0}\" configured as federated, currently it is not fully supported, wait for future releases", user.GetUsername()) service.logger.Warn(msg) return &data.OperationError{Msg: "federated user not supported", Description: msg} } else { - // todo(UMV): use hash instead raw passwords - password := user.GetPassword() - if password != tokenIssueData.Password { + oldPasswordHash := user.GetPasswordHash() + if !realm.Encoder.IsPasswordsMatch(tokenIssueData.Password, oldPasswordHash) { service.logger.Trace("Credential check: password mismatch") return &data.OperationError{Msg: errors.InvalidUserCredentialsMsg, Description: errors.InvalidUserCredentialsDesc} } diff --git a/tools/create_wissance_demo_users_docker.sh b/tools/create_wissance_demo_users_docker.sh index ccb1db5..fd7ed59 100644 --- a/tools/create_wissance_demo_users_docker.sh +++ b/tools/create_wissance_demo_users_docker.sh @@ -1,3 +1,8 @@ +# Realm WissanceFerrumDemo ./ferrum-admin --config=config_docker_w_redis.json --resource=realm --operation=create --value='{"name": "WissanceFerrumDemo", "user_federation_services":[], "token_expiration": 600, "refresh_expiration": 300}' ./ferrum-admin --config=config_docker_w_redis.json --resource=client --operation=create --value='{"id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e6667", "name": "WissanceWebDemo", "type": "confidential", "auth": {"type": 1, "value": "fb6Z4RsOadVycQoeQiN57xpu8w8w1111"}}' --params="WissanceFerrumDemo" ./ferrum-admin --config=config_docker_w_redis.json --resource=user --operation=create --value='{"info": {"sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac6891", "email_verified": true, "roles": ["admin"], "name": "M.V.Ushakov", "preferred_username": "umv", "given_name": "Michael", "family_name": "Ushakov"}, "credentials": {"password": "1s2d3f4g90xs"}}' --params="WissanceFerrumDemo" +# Realm WissanceFerrumDemo2 +./ferrum-admin --config=config_docker_w_redis.json --resource=realm --operation=create --value='{"name": "WissanceFerrumDemo2", "user_federation_services":[], "token_expiration": 600, "refresh_expiration": 300}' +./ferrum-admin --config=config_docker_w_redis.json --resource=client --operation=create --value='{"id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e6668", "name": "WissanceWebDemo2", "type": "confidential", "auth": {"type": 1, "value": "fb6Z4RsOadVycQoeQiN57xpu8w8w2222"}}' --params="WissanceFerrumDemo2" +./ferrum-admin --config=config_docker_w_redis.json --resource=user --operation=create --value='{"info": {"sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac6892", "email_verified": true, "roles": ["manager"], "name": "A.Petrov", "preferred_username": "paa", "given_name": "Alex", "family_name": "Petrov"}, "credentials": {"password": "12345678"}}' --params="WissanceFerrumDemo2" diff --git a/utils/encoding/encoding.go b/utils/encoding/encoding.go new file mode 100644 index 0000000..d513de0 --- /dev/null +++ b/utils/encoding/encoding.go @@ -0,0 +1,58 @@ +package encoding + +import ( + "crypto/sha512" + "encoding/base64" + "hash" + "math/rand" +) + +type PasswordJsonEncoder struct { + salt string + hasher hash.Hash +} + +func NewPasswordJsonEncoder(salt string) *PasswordJsonEncoder { + encoder := PasswordJsonEncoder{ + hasher: sha512.New(), + salt: salt, + } + return &encoder +} + +func (e *PasswordJsonEncoder) GetB64PasswordHash(password string) string { + passwordBytes := []byte(password + e.salt) + e.hasher.Write(passwordBytes) + hashedPasswordBytes := e.hasher.Sum(nil) + e.hasher.Reset() + + b64encoded := b64Encode(hashedPasswordBytes) + return b64encoded +} + +func (e *PasswordJsonEncoder) IsPasswordsMatch(password, hash string) bool { + currPasswordHash := e.GetB64PasswordHash(password) + return b64Decode(hash) == b64Decode(currPasswordHash) +} + +func GenerateRandomSalt() string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+=" + salt := make([]byte, 32) + for i := range salt { + salt[i] = charset[rand.Intn(len(charset))] + } + return string(salt) +} + +func b64Encode(encoded []byte) string { + cstr := base64.URLEncoding.EncodeToString(encoded) + return cstr +} + +func b64Decode(encoded string) string { + cstr, err := base64.URLEncoding.DecodeString(encoded) + if err != nil { + return "" + } + return string(cstr) +} diff --git a/utils/encoding/encoding_test.go b/utils/encoding/encoding_test.go new file mode 100644 index 0000000..8402c24 --- /dev/null +++ b/utils/encoding/encoding_test.go @@ -0,0 +1,23 @@ +package encoding + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_HashPassword(t *testing.T) { + t.Run("success", func(t *testing.T) { + // Arrange + pwd := "qwerty" + salt := "salt" + encoder := NewPasswordJsonEncoder(salt) + + // Act + hashedPwd := encoder.GetB64PasswordHash(pwd) + isMatch := encoder.IsPasswordsMatch(pwd, hashedPwd) + + // Assert + assert.True(t, isMatch) + }) +}