├── .env ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .golangci.yaml ├── .idea ├── Ferrum.iml ├── modules.xml ├── vcs.xml └── workspace.xml ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── admin │ └── cli │ │ ├── README.md │ │ ├── data_context_extensions.go │ │ ├── main.go │ │ └── operations │ │ └── operations.go ├── rest │ ├── common.go │ ├── context.go │ ├── utilities.go │ ├── utilities_test.go │ └── web_api_handler.go └── routing_fuzzing_test.go ├── application ├── application.go ├── application_runner.go └── application_test.go ├── certs ├── server.crt └── server.key ├── config.json ├── config ├── app_config.go ├── app_config_test.go ├── credentials_config.go ├── data_source_config.go ├── logs_config.go ├── mongodb_config.go ├── redis_options_config.go ├── server_config.go ├── test_configs │ └── valid_config_w_min_redis.json └── validating_config.go ├── config_docker_w_redis.json ├── config_w_redis.json ├── data.json ├── data ├── authentication.go ├── authentication_defs.go ├── client.go ├── keycloak_user.go ├── keycloak_user_test.go ├── object_ext_id.go ├── operation_error.go ├── realm.go ├── server.go ├── session.go ├── token.go ├── user.go └── user_federation_service_config.go ├── docker-compose.yml ├── docs ├── 2024681456_Свидетельство_ЭВМ_Ferrum_Community_Auth_Server.pdf ├── nginx │ └── nginx_docker.conf └── rus_software │ ├── .~lock.ferrum_usage.doc# │ └── ferrum_usage.doc ├── dto ├── errors_details.go ├── introspect_token_result.go ├── openid_configuration.go ├── token.go └── token_generation_data.go ├── errors ├── consts.go ├── data_operations_errors.go └── user_federation_service_error.go ├── extTestApps └── Wissance.Auth.FerrumChecker │ ├── Wissance.Auth.FerrumChecker.sln │ └── Wissance.Auth.FerrumChecker │ ├── Program.cs │ └── Wissance.Auth.FerrumChecker.csproj ├── globals └── keycloak_defs.go ├── go.mod ├── go.sum ├── img ├── additional │ ├── cli_from_docker.png │ ├── configuration_endpoint.png │ └── start_via_docker_compose.png ├── ferrum_cover.png └── ferrum_cover_sm.png ├── keyfile ├── logging └── logger.go ├── main.go ├── managers ├── data_context.go ├── files │ ├── manager.go │ ├── manager_test.go │ └── test_data.json └── redis │ ├── manager.go │ ├── manager_client_operations.go │ ├── manager_realm_operations.go │ ├── manager_test.go │ ├── manager_user_federation_service_operations.go │ └── manager_user_operations.go ├── services ├── federation │ ├── ldap_federation_service.go │ └── user_federation_service.go ├── jwt_generator_service.go ├── security.go └── token_based_security.go ├── swagger ├── docs.go ├── swagger.json └── swagger.yaml ├── testData ├── redis │ ├── data.md │ └── insert_test_data.py └── requests │ └── wissance.ferrum.postman_collection.json ├── tools ├── create_wissance_demo_users.ps1 ├── create_wissance_demo_users_docker.sh ├── docker_app_runner.sh └── init_script.sh └── utils ├── encoding ├── encoding.go └── encoding_test.go ├── jsontools └── json_merge.go ├── transformers └── redis_cfg_transformer.go └── validators └── common.go /.env: -------------------------------------------------------------------------------- 1 | # 1. REDIS_ARGS is required for redis container, it also has Redis username && password 2 | REDIS_ARGS="--user ferrum_db on >FeRRuM000 allkeys allchannels allcommands --user default off --save 20 1" 3 | # -devmode enables swagger 4 | # 2. This is service variables for swagger running, to remove swagger in app set FERRUM_ADDITIONAL_OPTS="" 5 | FERRUM_ADDITIONAL_OPTS="-devmode" 6 | FERRUM_SWAGGER_EXT_ADDRESS="127.0.0.1" 7 | # 3. tech env vars that overrides config values in config_docker_w_redis.json 8 | __data_source.credentials.username="ferrum_db" 9 | __data_source.credentials.password="FeRRuM000" 10 | __logging.level="debug" 11 | 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh text eol=lf # linux line-endings -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Go CI 2 | 3 | on: 4 | pull_request: 5 | branches: [develop, master] 6 | push: 7 | branches: [develop, master] 8 | 9 | jobs: 10 | build-linux: 11 | name: Build Ferrum on linux 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4.1.7 15 | - name: Set up Go 16 | uses: actions/setup-go@v5.2.0 17 | with: 18 | go-version: '1.21' 19 | - name: Build 20 | run: go version && go build -v ./... 21 | 22 | build-windows: 23 | name: Build Ferrum on windows 24 | runs-on: windows-latest 25 | steps: 26 | - uses: actions/checkout@v4.1.7 27 | - name: Set up Go 28 | uses: actions/setup-go@v5.2.0 29 | with: 30 | go-version: '1.21' 31 | - name: Build 32 | run: go version && go build -v ./... 33 | 34 | all-tests-linux: 35 | name: Run all tests on linux 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4.1.7 39 | - name: Set up Go 40 | uses: actions/setup-go@v5.2.0 41 | with: 42 | go-version: '1.21' 43 | - name: Set up Redis Stack server 44 | run: docker compose up -d redis 45 | - name: Get Redis logs 46 | run: docker logs $(docker ps -aqf "name=wissance_ferrum_db") 47 | - name: Test all 48 | run: go version && go mod tidy && go test -v ./... 49 | 50 | #all-tests-windows: 51 | # name: Run all tests on windows 52 | # runs-on: windows-latest 53 | # steps: 54 | # - uses: actions/checkout@v4.1.7 55 | # - name: Set up Go 56 | # uses: actions/setup-go@v5.0.2 57 | # - name: Redis Stack Server 58 | # run: docker compose up -d redis 59 | # - name: Get Redis logs 60 | # run: docker logs $(docker ps -aqf "name=wissance_ferrum_db") 61 | # - name: Test all 62 | # run: go test -v ./... 63 | # on windows don't work linux containers by default 64 | 65 | lint: 66 | name: Run golangci linters 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v4.1.7 70 | - name: Set up Go 71 | uses: actions/setup-go@v5.2.0 72 | with: 73 | go-version: '1.21' 74 | - name: Run golangci-lint 75 | uses: golangci/golangci-lint-action@v6.1.0 76 | with: 77 | version: v1.63.4 78 | args: --timeout 3m --config .golangci.yaml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | logs/ 18 | 19 | extTestApps/Wissance.Auth.FerrumChecker/.vs/ 20 | 21 | extTestApps/Wissance.Auth.FerrumChecker/Wissance.Auth.FerrumChecker/bin/ 22 | 23 | extTestApps/Wissance.Auth.FerrumChecker/Wissance.Auth.FerrumChecker/obj/ 24 | 25 | .vscode/ -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | # timeout for analysis, e.g. 30s, 5m, default is 1m 3 | timeout: 30m 4 | 5 | modules-download-mode: readonly 6 | 7 | go: '1.21' 8 | 9 | output: 10 | # colored-line-number|line-number|json|tab|checkstyle|code-climate|junit-xml|github-actions 11 | # default is "colored-line-number" 12 | formats: code-climate 13 | 14 | linters: 15 | enable-all: false 16 | disable: 17 | - exhaustruct 18 | - gofumpt 19 | - testpackage 20 | - depguard 21 | - tagliatelle 22 | - ireturn 23 | - varnamelen 24 | - wrapcheck 25 | 26 | linters-settings: 27 | stylecheck: 28 | # Select the Go version to target. The default is '1.13'. 29 | # https://staticcheck.io/docs/options#checks 30 | checks: [ "all", "-ST1000" ] 31 | funlen: 32 | lines: 100 33 | gci: 34 | sections: 35 | - standard 36 | - default 37 | - prefix(github.com/wissance/Ferrum) 38 | gocyclo: 39 | min-complexity: 5 40 | varnamelen: 41 | ignore-names: 42 | - id 43 | ignore-decls: 44 | - ok bool 45 | wrapcheck: 46 | ignorePackageGlobs: 47 | - google.golang.org/grpc/status 48 | - github.com/pkg/errors 49 | - golang.org/x/sync/errgroup 50 | gosec: 51 | excludes: 52 | - G204 53 | 54 | issues: 55 | exclude-rules: 56 | - path: _test\.go 57 | linters: 58 | - containedctx 59 | - gocyclo 60 | - cyclop 61 | - funlen 62 | - goerr113 63 | - varnamelen 64 | - staticcheck 65 | - maintidx 66 | - lll 67 | - paralleltest 68 | - dupl 69 | - typecheck 70 | - wsl 71 | - govet 72 | - path: main\.go 73 | linters: 74 | - gochecknoglobals 75 | - lll 76 | - funlen 77 | -------------------------------------------------------------------------------- /.idea/Ferrum.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/workspace.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 24 | 29 | 30 | 31 | 32 | 34 | 35 | 37 | 38 | 39 | 42 | { 43 | "keyToString": { 44 | "DefaultGoTemplateProperty": "Go File", 45 | "RunOnceActivity.OpenProjectViewOnStart": "true", 46 | "RunOnceActivity.ShowReadmeOnStart": "true", 47 | "RunOnceActivity.go.format.on.save.advertiser.fired": "true", 48 | "RunOnceActivity.go.formatter.settings.were.checked": "true", 49 | "RunOnceActivity.go.migrated.go.modules.settings": "true", 50 | "RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true", 51 | "RunOnceActivity.go.watchers.conflict.with.on.save.actions.check.performed": "true", 52 | "WebServerToolWindowFactoryState": "false", 53 | "go.import.settings.migrated": "true", 54 | "go.sdk.automatically.set": "true", 55 | "last_opened_file_path": "C:/Users/mushakov/projects/soar", 56 | "nodejs_package_manager_path": "npm", 57 | "settings.editor.selected.configurable": "go.sdk" 58 | } 59 | } 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 105 | 106 | 115 | 116 | 117 | true 118 | 119 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine 2 | VOLUME /app_data 3 | VOLUME /nginx_cfg 4 | 5 | RUN sed -i 's/https/http/' /etc/apk/repositories 6 | RUN apk update && apk add --no-cache git && apk add --no-cache bash && apk add --no-cache build-base && apk add --no-cache openssl 7 | 8 | RUN apk add --update --no-cache python3 && ln -sf python3 /usr/bin/python && apk add py3-pip 9 | RUN apk add py3-setuptools && apk add py3-redis 10 | 11 | RUN mkdir /app 12 | WORKDIR /app 13 | 14 | COPY api ./api 15 | COPY application ./application 16 | COPY certs ./certs 17 | COPY config ./config 18 | COPY data ./data 19 | COPY dto ./dto 20 | COPY errors ./errors 21 | COPY globals ./globals 22 | COPY logging ./logging 23 | COPY managers ./managers 24 | COPY services ./services 25 | COPY utils ./utils 26 | COPY "go.mod" ./"go.mod" 27 | COPY "go.sum" ./"go.sum" 28 | COPY keyfile ./keyfile 29 | COPY "main.go" ./"main.go" 30 | COPY "config_docker_w_redis.json" ./"config_docker_w_redis.json" 31 | COPY tools/"create_wissance_demo_users_docker.sh" ./"create_wissance_demo_users_docker.sh" 32 | COPY tools/"docker_app_runner.sh" ./"docker_app_runner.sh" 33 | COPY docs/nginx/"nginx_docker.conf" /nginx_cfg/"nginx.conf" 34 | COPY swagger ./swagger 35 | # TODO(UMV): I need to create dhparam directory in VOLUME, there are no other way or i have not found it yet 36 | # COPY "LICENSE" /nginx_cfg/dhparam/ 37 | RUN mkdir -p /nginx_cfg/dhparam && mkdir -p /nginx_cfg/certs && mkdir -p /nginx_cfg/conf.d 38 | 39 | RUN go mod tidy && go generate 40 | # Download all the dependencies 41 | RUN go get -d -v ./... 42 | RUN go install -v ./... 43 | 44 | # Build the Go apps 45 | RUN go build -o ferrum 46 | RUN go build -o ferrum-admin ./api/admin/cli 47 | 48 | # TODO(SIA) Vulnerability 49 | COPY --from=ghcr.io/ufoscout/docker-compose-wait:latest /wait /wait 50 | 51 | COPY testData ./testData 52 | COPY tools ./tools 53 | 54 | # TODO(UMV): 1. Build config on a Fly (to use props from Env variables) 55 | 56 | # TODO(UMV): 2. If we have users, realms and clients do not attempt to insert them 57 | 58 | CMD ["/bin/bash", "-c", "./docker_app_runner.sh"] 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ferrum 2 | 3 | Ferrum is a **better** Authorization Server, this is a Community version. 4 | 5 | ![GitHub go.mod Go version (subdirectory of monorepo)](https://img.shields.io/github/go-mod/go-version/wissance/Ferrum?style=plastic) 6 | ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/wissance/Ferrum?style=plastic) 7 | ![GitHub issues](https://img.shields.io/github/issues/wissance/Ferrum?style=plastic) 8 | ![GitHub Release Date](https://img.shields.io/github/release-date/wissance/Ferrum) 9 | ![GitHub release (latest by date)](https://img.shields.io/github/downloads/wissance/Ferrum/v0.9.2/total?style=plastic) 10 | 11 | ![Ferrum: A better Auth Server](/img/ferrum_cover.png) 12 | 13 | ## 1. Communication 14 | 15 | * Discord channel : https://discord.gg/9RYNYu2Mxq 16 | 17 | ## 2. General info 18 | 19 | `Ferrum` is `OpenId-Connect` Authorization server written on GO. It has Data Contract similar to 20 | `Keycloak` server (**minimal `Keycloak`** and we'll grow to full-fledged `KeyCloak` analog). 21 | 22 | Today we are having **following features**: 23 | 24 | 1. Issue new tokens. 25 | 2. Refresh tokens. 26 | 2. Control user sessions (token expiration). 27 | 3. Get UserInfo. 28 | 4. Token Introspect. 29 | 4. Managed from external code (`Start` and `Stop`) making them an ***ideal candidate*** for using in ***integration 30 | tests*** for WEB API services that uses `Keycloak` as authorization server; 31 | 5. Ability to use different data storage: 32 | * `FILE` data storage for small Read only systems 33 | * `REDIS` data storage for systems with large number of users and small response time; 34 | 6. Ability to use any user data and attributes (any valid JSON but with some requirements), if you have to 35 | properly configure your users just add what user have to `data.json` or in memory 36 | 7. Ability to ***become high performance enterprise level Authorization server***. 37 | 38 | it has `endpoints` SIMILAR to `Keycloak`, at present time we are having following: 39 | 40 | 1. Issue and Refresh tokens: `POST ~/auth/realms/{realm}/protocol/openid-connect/token` 41 | 2. Get UserInfo `GET ~/auth/realms/{realm}/protocol/openid-connect/userinfo` 42 | 3. Introspect tokens `POST ~/auth/realms/{realm}/protocol/openid-connect/token/introspect` 43 | 44 | ## 3. How to use 45 | 46 | `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. 47 | 48 | ### 3.1 Build 49 | 50 | First of all build is simple run `go build` from application root directory. Additionally it is possible 51 | to generate self signed certificates - run `go generate` from command line 52 | 53 | If you don't specify the name of executable (by passing -o {execName} to go build) than name of executable = name of project 54 | 55 | ### 3.2 Run application as Standalone 56 | 57 | Run is simple (`Ferrum` starts with default config - `config.json`): 58 | ```ps1 59 | ./Ferrum 60 | ``` 61 | 62 | To run `Ferrum` with selected config i.e. `config_w_redis.json` : 63 | 64 | ```ps1 65 | ./Ferrum --config ./config_w_redis.json 66 | ``` 67 | 68 | ### 3.3 Run application in docker 69 | 70 | It is possible to start app in docker with already installed `REDIS` and with initial data (see python 71 | data insert script): 72 | 73 | ```ps1 74 | docker-compose up --build 75 | ``` 76 | 77 | ### 3.4 Run with direct configuration && data pass from code (embedding Authorization server in you applications) 78 | 79 | There are 2 ways to use `Ferrum`: 80 | 1. Start with config file (described above) 81 | 2. Start with direct pass `config.AppConfig` and `data.ServerData` in application, i.e. 82 | ```go 83 | app := CreateAppWithData(appConfig, &testServerData, testKey) 84 | res, err := app.Init() 85 | assert.True(t, res) 86 | assert.Nil(t, err) 87 | 88 | res, err = app.Start() 89 | assert.True(t, res) 90 | assert.Nil(t, err) 91 | // do what you should ... 92 | app.Stop() 93 | ``` 94 | 95 | ### Test 96 | At present moment we have 2 fully integration tests, and number of them continues to grow. To run test execute from cmd: 97 | ```ps1 98 | go test 99 | ``` 100 | For running Manager tests on `Redis` you must have redis on `127.0.0.1:6379` with `ferrum_db` / `FeRRuM000` `auth` `user+password` 101 | pair, it is possible to start docker_compose and test on compose `ferrum_db` container 102 | 103 | ## 4. Configure 104 | 105 | ### 4.1 Server configuration 106 | 107 | Configuration splitted onto several sections: 108 | 109 | ```json 110 | "server": { 111 | "schema": "https", 112 | "address": "localhost", 113 | "port": 8182, 114 | "security": { 115 | "key_file": "./certs/server.key", 116 | "certificate_file": "./certs/server.crt" 117 | } 118 | } 119 | ``` 120 | - data file: `realms`, `clients` and `users` application takes from this data file and stores in 121 | app memory, data file name - `data.json` 122 | - key file that is using for `JWT` tokens generation (`access_token` && `refresh_token`), 123 | name `keyfile` (without extensions). 124 | 125 | ### 4.2 Configure user data as you wish 126 | 127 | Users does not have any specific structure, you could add whatever you want, but for compatibility 128 | with keycloak and for ability to check password minimal user looks like: 129 | ```json 130 | { 131 | "info": { 132 | "sub": "" // <-- THIS PROPERTY USED AS ID, PROBABLY WE SHOULD CHANGE THIS TO ID 133 | "preferred_username": "admin", // <-- THIS IS REQUIRED 134 | ... 135 | }, 136 | "credentials": { 137 | "password": "1s2d3f4g90xs" // <-- TODAY WE STORE PASSWORDS AS OPENED 138 | } 139 | } 140 | ``` 141 | 142 | in this minimal user example you could expand `info` structure as you want, `credentials` is a service structure, 143 | there are NO SENSES in modifying it. 144 | 145 | ### 4.3 Server embedding into application (use from code) 146 | 147 | Minimal full example of how to use coud be found in `application_test.go`, here is a minimal snippet: 148 | 149 | ```go 150 | var testKey = []byte("qwerty1234567890") 151 | var testServerData = data.ServerData{ 152 | Realms: []data.Realm{ 153 | {Name: "testrealm1", TokenExpiration: 10, RefreshTokenExpiration: 5, 154 | Clients: []data.Client{ 155 | {Name: "testclient1", Type: data.Confidential, Auth: data.Authentication{Type: data.ClientIdAndSecrets, 156 | Value: "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz"}}, 157 | }, Users: []interface{}{ 158 | map[string]interface{}{"info": map[string]interface{}{"sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 159 | "name": "vano", "preferred_username": "vano", 160 | "given_name": "vano ivanov", "family_name": "ivanov", "email_verified": true}, 161 | "credentials": map[string]interface{}{"password": "1234567890"}}, 162 | }}, 163 | }, 164 | } 165 | var httpsAppConfig = config.AppConfig{ServerCfg: config.ServerConfig{Schema: config.HTTPS, Address: "127.0.0.1", Port: 8672, 166 | Security: config.SecurityConfig{KeyFile: "./certs/server.key", CertificateFile: "./certs/server.crt"}}} 167 | 168 | app := CreateAppWithData(appConfig, &testServerData, testKey) 169 | res, err := app.Init() 170 | if err != nil { 171 | // handle ERROR 172 | } 173 | 174 | res, err = app.Start() 175 | 176 | if err != nil { 177 | // handle ERROR 178 | } 179 | 180 | // do whatever you want 181 | 182 | app.Stop() 183 | ``` 184 | 185 | ## 5. Server administer 186 | 187 | Since version `0.9.1` it is possible to use `CLI Admin` [See](api/admin/cli/README.md) 188 | 189 | ### 5.1 Use CLI admin in a docker 190 | 191 | 1. Run docker compose - `docker compose up --build` 192 | 2. List running containers - `docker ps -a` 193 | 3. Attach to running container using listed hash `docker exec -it 060cfb8dd84c sh` 194 | 4. Run admin interface providing a valid config `ferrum-admin --config=config_docker_w_redis.json ...`, see picture 195 | 196 | ![Use CLI Admin from docker](/img/additional/cli_from_docker.png) 197 | 198 | ## 6. Changes 199 | 200 | Brief info about changes in releases. 201 | 202 | ### 6.1 Changes in 0.0.1 203 | 204 | Features: 205 | * `Keycloak` compatible HTTP-endpoints to issue a new `token` and to get `userinfo` 206 | 207 | ### 6.2 Changes in 0.1.0 208 | 209 | Features: 210 | * documentation (`readme.md` file) 211 | * integration tests 212 | 213 | ### 6.3 Changes in 0.1.1 214 | 215 | Features: 216 | * fixed modules names 217 | 218 | ### 6.4 Changes in 0.1.2 219 | 220 | Features: 221 | * changed module names to make it available to embed `Ferrum` in an other applications 222 | 223 | ### 6.5 Changes in 0.1.3 224 | 225 | Features: 226 | * `Keycloak` compatible HTTP-endpoint for token introspect 227 | 228 | ### 6.6 Changes in 0.1.4 229 | 230 | Features: 231 | * removed `/` therefore it is possible to interact with `Ferrum` using `go-cloak` package 232 | 233 | ### 6.7 Changes in 0.9.0 234 | 235 | Features 236 | * logging 237 | * implemented token refresh 238 | * better docs 239 | 240 | ### 6.8 Changes in 0.9.1 241 | 242 | Features: 243 | * `docker` && `docker-compose` for app running 244 | * admin `CLI` `API` 245 | * `Redis` as a production data storage 246 | 247 | ### 6.9 Changes in 0.9.2 248 | 249 | Features: 250 | * admin cli added to docker 251 | * test on `Redis` data manger 252 | * used different config to run locally and in docker 253 | * newer `Keycloak` versions support 254 | * checked stability if `Redis` is down, `Ferrum` does not crushes and wait until `Redis` is ready 255 | * `swagger` (`-devmode` option in cmd line) and `Keycloak` compatible HTTP endpoint `openid-configuration` 256 | * support for federated user (without full providers impl, just preliminary) 257 | * store password as a hashes 258 | 259 | ## 7. Contributors 260 | 261 | 262 | 263 | 264 | -------------------------------------------------------------------------------- /api/admin/cli/README.md: -------------------------------------------------------------------------------- 1 | ### 1. Ferrum CLI Admin console 2 | 3 | `CLI Admin` is an administrative console that allows to manage all `Ferrum` CLI has a following main peculiarities: 4 | 5 | 1. It is separate console executable utility 6 | 2. Shares same codebase 7 | 3. Use `Ferrum` config file (provides as an argument) 8 | 9 | Admin CLI could be build as follows: 10 | 11 | ```ps1 12 | go build -o ferrum-admin.exe ./api/admin/cli 13 | ``` 14 | 15 | ### 2. Ferrum CLI Admin operations 16 | 17 | All Admin CLI operation have the same scheme as follows: 18 | `{admin_cli_executable} --resource={resorce_name} --operation={operation_type} [additional_arguments]` 19 | where: 20 | * `{admin_cli_executable}` is a name of executable file 21 | * `{resource_name}` - `realm`, `client`, `user` or `user_federation` 22 | * `{operation_type}` is an operation to perform over resource (see operation description below) 23 | * `[additional_arguments]` a set of additional `--key=value` pairs i.e. resource id (for get), or value (for create and|or update) 24 | 25 | #### 2.1 Operations 26 | 27 | `CLI` allows to perform standard CRUD operation via console (`create`, `read`, `update`, `delete`) and some additional 28 | operations: 29 | 30 | * `reset_password` - reset password to random value 31 | * `change_password` - changes password to provided 32 | 33 | !!! Important NOTE !!! : in some of a systems to pass `JSON` via command line all **`"` should be escaped as `\"`** . 34 | 35 | ##### 2.1.1 Standard CRUD operations 36 | 37 | ##### 2.1.1.1 Create operations 38 | 39 | Create operation should provide `--value` with resource body, key will be constructed from body. For `client` and `user` creation realm id (name) 40 | must be provided via `--params`. 41 | 42 | Create `realm` example 43 | ```ps1 44 | ./ferrum-admin.exe --resource=realm --operation=create --value='{\"name\": \"WissanceFerrumDemo\", \"token_expiration\": 600, \"refresh_expiration\": 300}' 45 | ``` 46 | 47 | Create `client` example: 48 | ```ps1 49 | ./ferrum-admin.exe --resource=client --operation=create --value='{\"id\": \"d4dc483d-7d0d-4d2e-a0a0-2d34b55e6666\", \"name\": \"WissanceWebDemo\", \"type\": \"confidential\", \"auth\": {\"type\": 1, \"value\": \"fb6Z4RsOadVycQoeQiN57xpu8w8wTEST\"}}' --params=WissanceFerrumDemo 50 | ``` 51 | 52 | Create `user` example: 53 | ```ps1 54 | ./ferrum-admin.exe --resource=user --operation=create --value='{\"info\": {\"sub\": \"667ff6a7-3f6b-449b-a217-6fc5d9ac6890\", \"email_verified\": true, \"roles\": [\"admin\"], \"name\": \"M.V.Ushakov\", \"preferred_username\": \"umv\", \"given_name\": \"Michael\", \"family_name\": \"Ushakov\"}, \"credentials\": {\"password\": \"1s2d3f4g90xs\"}}' --params=WissanceFerrumDemo 55 | ``` 56 | 57 | Create `user_federation` example: 58 | ```ps1 59 | ./ferrum-admin.exe --resource=user_federation --operation=create --value='{\"name\":\"test_ldap\", \"type\":\"ldap\", \"url\":\"ldap://ldap.wissance.com:389\"}' --params=WissanceFerrumDemo 60 | ``` 61 | ##### 2.1.1.2 Update operations 62 | 63 | Update operation fully replace item by key `--resource_id` + `--param={realm_name}` (realm does not requires) 64 | New key content provides via `--value=`. Why we don't provide just a DB key? Answer is there are could be different storage 65 | and key is often composite, therefore it is more user-friendly to provide separately key and realn 66 | 67 | Update `realm` example 68 | ```ps1 69 | ./ferrum-admin.exe --resource=realm --operation=update --resource_id=WissanceFerrumDemo --value='{"name": "WissanceFerrumDemo", "token_expiration": 2400, "refresh_expiration": 1200}' 70 | ``` 71 | 72 | Update `client` example: 73 | ```ps1 74 | ./ferrum-admin.exe --resource=client --operation=update --resource_id=WissanceWebDemo --value='{\"id\": \"d4dc483d-7d0d-4d2e-a0a0-2d34b55e6666\", \"name\": \"WissanceWebDemo\", \"type\": \"confidential\", \"auth\": {\"type\": 2, \"value\": \"fb6Z4RsOadVycQoeQiN57xpu8w8wTEST\"}}' --params=WissanceFerrumDemo 75 | ``` 76 | 77 | Update `user` example: 78 | ```ps1 79 | ./ferrum-admin.exe --resource=user --operation=update --resource_id=umv --value='{\"info\": {\"sub\": \"667ff6a7-3f6b-449b-a217-6fc5d9ac6890\", \"email_verified\": true, \"roles\": [\"admin\", \"managers\"], \"name\": \"M.V.Ushakov\", \"preferred_username\": \"umv\", \"given_name\": \"Michael\", \"family_name\": \"Ushakov\"}, \"credentials\": {\"password\": \"1s2d3f4g90xs\"}}' --params=WissanceFerrumDemo 80 | ``` 81 | 82 | Update `user_federation` example: 83 | ```ps1 84 | ./ferrum-admin.exe --resource=user_federation --operation=update --resource_id=test_ldap --value='{\"name\":\"test_ldap\", \"type\":\"ldap\", \"url\":\"ldap://custom_ldap.wissance.com:389\"}' --params=WissanceFerrumDemo 85 | ``` 86 | 87 | Question: 88 | 1. What is using for user identification, because it has `preferred_username`, and `given_name` fields. I've not tested this yet but `preferred_username` must be used as `resource_id`. Here and in all `CRUD` operations that are requires identifier. 89 | 90 | ##### 2.1.1.3 Get operations 91 | 92 | **Get by id operation** requires resource identifier (`resource_id`) and realm name via `--params`. 93 | 94 | Get `realm` example: 95 | ```ps1 96 | ./ferrum-admin.exe --resource=realm --operation=get --resource_id=WissanceFerrumDemo 97 | ``` 98 | 99 | Get `client` example: 100 | ```ps1 101 | ./ferrum-admin.exe --resource=client --operation=get --resource_id=WissanceWebDemo --params=WissanceFerrumDemo 102 | ``` 103 | 104 | Get `user` example: 105 | ```ps1 106 | ./ferrum-admin.exe --resource=user --operation=get --resource_id=umv --params=WissanceFerrumDemo 107 | ``` 108 | Get user should hide credential section (have to test, not tested yet). 109 | 110 | Get `user_federation` example: 111 | ```ps1 112 | ./ferrum-admin.exe --resource=user_federation --operation=get --resource_id=test_ldap --params=WissanceFerrumDemo 113 | ``` 114 | 115 | ##### 2.1.1.3 Delete operations 116 | 117 | Delete operation requires `--resource_id` and `--params` to be provided. 118 | 119 | Delete `realm` example: 120 | ```ps1 121 | ./ferrum-admin.exe --resource=realm --operation=delete --resource_id=WissanceFerrumDemo 122 | ``` 123 | 124 | Delete `client` example: 125 | ```ps1 126 | ./ferrum-admin.exe --resource=client --operation=delete --resource_id=WissanceWebDemo --params=WissanceFerrumDemo 127 | ``` 128 | 129 | Delete `user` example: 130 | ```ps1 131 | ./ferrum-admin.exe --resource=user --operation=delete --resource_id=umv --params=WissanceFerrumDemo 132 | ``` 133 | 134 | Delete `user_federation` example: 135 | ```ps1 136 | ./ferrum-admin.exe --resource=user_federation --operation=delete --resource_id=test_ldap --params=WissanceFerrumDemo 137 | ``` 138 | 139 | Questions (todo for work): 140 | 1. What happened to clients and users if realm was deleted ? Should be a CASCADE removing. 141 | 142 | ##### 2.1.2 Additional operations 143 | 144 | ###### 2.1.2.1 User password reset 145 | 146 | Password reset makes set `user` password value to random, new password outputs to console. As for get, update or delete 147 | operation it requires username to be provided via `--resource_id` and a realm name via `--params`, example: 148 | ```ps1 149 | ./ferrum-admin.exe --resource=user --operation=reset_password --resource_id=umv --params=WissanceFerrumDemo 150 | ``` 151 | 152 | ###### 2.1.2.1 User password change 153 | 154 | Password change requires username to be provided via `--resource_id` and a realm name via `--params. New password 155 | is passing via `--value=`, example: 156 | 157 | ```ps1 158 | ./ferrum-admin.exe --resource=user --operation=change_password --resource_id=umv --value='newPassword' --params=WissanceFerrumDemo 159 | ``` 160 | -------------------------------------------------------------------------------- /api/admin/cli/data_context_extensions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type PasswordManager interface { 4 | SetPassword(realmName string, userName string, password string) error 5 | } 6 | -------------------------------------------------------------------------------- /api/admin/cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base32" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "log" 10 | 11 | "github.com/wissance/Ferrum/managers" 12 | 13 | "github.com/wissance/Ferrum/api/admin/cli/operations" 14 | "github.com/wissance/Ferrum/config" 15 | "github.com/wissance/Ferrum/data" 16 | "github.com/wissance/Ferrum/logging" 17 | sf "github.com/wissance/stringFormatter" 18 | ) 19 | 20 | const defaultConfig = "./config_w_redis.json" 21 | 22 | var ( 23 | argConfigFile = flag.String("config", defaultConfig, "Application config for working with a persistent data store") 24 | argOperation = flag.String("operation", "", "One of the available operations read|create|update|delete or user specific change/reset password") 25 | argResource = flag.String("resource", "", "\"realm\", \"client\" or \"user\" or maybe other in future") 26 | argResourceId = flag.String("resource_id", "", "resource object identifier, id required for the update|delete or read operation") 27 | argParams = flag.String("params", "", "Name of a realm for operations on client or user resources") 28 | argValue = flag.String("value", "", "Json encoded resource itself") 29 | ) 30 | 31 | func main() { 32 | flag.Parse() 33 | // TODO(UMV): extend config 34 | cfg, err := config.ReadAppConfig(*argConfigFile) 35 | if err != nil { 36 | log.Fatalf("readAppConfig failed: %s", err) 37 | } 38 | logger := logging.CreateLogger(&cfg.Logging) 39 | manager, err := managers.PrepareContext(&cfg.DataSource, logger) 40 | if err != nil { 41 | log.Fatalf("prepareContext failed: %s", err) 42 | } 43 | 44 | operation := operations.OperationType(*argOperation) 45 | resource := operations.ResourceType(*argResource) 46 | resourceId := *argResourceId 47 | params := *argParams 48 | value := []byte(*argValue) 49 | 50 | isInvalidOperation := operation != operations.GetOperation && operation != operations.CreateOperation && 51 | operation != operations.DeleteOperation && operation != operations.UpdateOperation && 52 | operation != operations.ChangePassword && operation != operations.ResetPassword 53 | if isInvalidOperation { 54 | log.Fatalf("bad Operation \"%s\"", operation) 55 | } 56 | // If there is a password change or password collection, it is not necessary to specify Resource 57 | if !(operation == operations.ChangePassword || operation == operations.ResetPassword) { 58 | isInvalidResource := resource != operations.RealmResource && resource != operations.ClientResource && resource != operations.UserResource 59 | if isInvalidResource { 60 | log.Fatalf("bad Resource \"%s\"", resource) 61 | } 62 | } 63 | if (resource == operations.ClientResource) || (resource == operations.UserResource) { 64 | if params == "" { 65 | log.Fatalf("Not specified Params") 66 | } 67 | } 68 | 69 | switch operation { 70 | case operations.GetOperation: 71 | if resourceId == "" { 72 | log.Fatalf("Not specified ResourceId") 73 | } 74 | switch resource { 75 | case operations.ClientResource: 76 | client, err := manager.GetClient(params, resourceId) 77 | if err != nil { 78 | log.Fatalf("GetClient failed: %s", err) 79 | } 80 | fmt.Println(*client) 81 | 82 | case operations.UserResource: 83 | user, err := manager.GetUser(params, resourceId) 84 | if err != nil { 85 | log.Fatalf("GetUser failed: %s", err) 86 | } 87 | fmt.Println(user.GetUserInfo()) 88 | 89 | case operations.RealmResource: 90 | realm, err := manager.GetRealm(resourceId) 91 | if err != nil { 92 | log.Fatalf("GetRealm failed: %s", err) 93 | } 94 | fmt.Println(*realm) 95 | 96 | case operations.UserFederationConfigResource: 97 | userFederation, err := manager.GetUserFederationConfig(params, resourceId) 98 | if err != nil { 99 | log.Fatalf("GetUserFederationConfig failed: %s", err) 100 | } 101 | fmt.Println(*userFederation) 102 | } 103 | 104 | return 105 | case operations.CreateOperation: 106 | if len(value) == 0 { 107 | log.Fatalf("Not specified Value") 108 | } 109 | switch resource { 110 | case operations.ClientResource: 111 | var clientNew data.Client 112 | if unmarshalErr := json.Unmarshal(value, &clientNew); unmarshalErr != nil { 113 | log.Fatal(sf.Format("json.Unmarshal failed: {0}", unmarshalErr.Error())) 114 | } 115 | if createErr := manager.CreateClient(params, clientNew); createErr != nil { 116 | log.Fatal(sf.Format("CreateClient failed: {0}", createErr.Error())) 117 | } 118 | log.Print(sf.Format("Client: \"{0}\" successfully created", clientNew.Name)) 119 | 120 | case operations.UserResource: 121 | var userNew any 122 | if err := json.Unmarshal(value, &userNew); err != nil { 123 | log.Fatalf("json.Unmarshal failed: %s", err) 124 | } 125 | realm, err := manager.GetRealm(params) 126 | if err != nil { 127 | log.Fatalf("GetRealm failed: %s", err) 128 | } 129 | user := data.CreateUser(userNew, realm.Encoder) 130 | if err := manager.CreateUser(params, user); err != nil { 131 | log.Fatalf("CreateUser failed: %s", err) 132 | } 133 | fmt.Println(sf.Format("User: \"{0}\" successfully created", user.GetUsername())) 134 | 135 | case operations.RealmResource: 136 | var newRealm data.Realm 137 | if err := json.Unmarshal(value, &newRealm); err != nil { 138 | log.Fatalf("json.Unmarshal failed: %s", err) 139 | } 140 | if err := manager.CreateRealm(newRealm); err != nil { 141 | log.Fatalf("CreateRealm failed: %s", err) 142 | } 143 | fmt.Println(sf.Format("Realm: \"{0}\" successfully created", newRealm.Name)) 144 | case operations.UserFederationConfigResource: 145 | var userFederationConfig data.UserFederationServiceConfig 146 | if err := json.Unmarshal(value, &userFederationConfig); err != nil { 147 | log.Fatalf("json.Unmarshal failed: %s", err) 148 | } 149 | if err := manager.CreateUserFederationConfig(params, userFederationConfig); err != nil { 150 | log.Fatalf("CreateUserFederationConfig failed: %s", err) 151 | } 152 | fmt.Println(sf.Format("User federation service config: \"{0}\" successfully created", userFederationConfig.Name)) 153 | } 154 | 155 | return 156 | case operations.DeleteOperation: 157 | if resourceId == "" { 158 | log.Fatalf("Not specified Resource_id") 159 | } 160 | switch resource { 161 | case operations.ClientResource: 162 | if err := manager.DeleteClient(params, resourceId); err != nil { 163 | log.Fatalf("DeleteClient failed: %s", err) 164 | } 165 | fmt.Println(sf.Format("Client: \"{0}\" successfully deleted", resourceId)) 166 | 167 | case operations.UserResource: 168 | if err := manager.DeleteUser(params, resourceId); err != nil { 169 | log.Fatalf("DeleteUser failed: %s", err) 170 | } 171 | fmt.Println(sf.Format("User: \"{0}\" successfully deleted", resourceId)) 172 | 173 | case operations.RealmResource: 174 | if err := manager.DeleteRealm(resourceId); err != nil { 175 | log.Fatalf("DeleteRealm failed: %s", err) 176 | } 177 | fmt.Println(sf.Format("Realm: \"{0}\" successfully deleted", resourceId)) 178 | 179 | case operations.UserFederationConfigResource: 180 | if err := manager.DeleteUserFederationConfig(params, resourceId); err != nil { 181 | log.Fatalf("DeleteUserFederationConfig failed: %s", err) 182 | } 183 | fmt.Println(sf.Format("User federation service config: \"{0}\" successfully deleted", resourceId)) 184 | } 185 | 186 | return 187 | case operations.UpdateOperation: 188 | if resourceId == "" { 189 | log.Fatalf("Not specified Resource_id") 190 | } 191 | if len(value) == 0 { 192 | log.Fatalf("Not specified Value") 193 | } 194 | switch resource { 195 | case operations.ClientResource: 196 | var newClient data.Client 197 | if err := json.Unmarshal(value, &newClient); err != nil { 198 | log.Fatalf("json.Unmarshal failed: %s", err) 199 | } 200 | if err := manager.UpdateClient(params, resourceId, newClient); err != nil { 201 | log.Fatalf("UpdateClient failed: %s", err) 202 | } 203 | fmt.Println(sf.Format("Client: \"{0}\" successfully updated", newClient.Name)) 204 | 205 | case operations.UserResource: 206 | var newUser any 207 | if err := json.Unmarshal(value, &newUser); err != nil { 208 | log.Fatalf("json.Unmarshal failed: %s", err) 209 | } 210 | user := data.CreateUser(newUser, nil) 211 | if err := manager.UpdateUser(params, resourceId, user); err != nil { 212 | log.Fatalf("UpdateUser failed: %s", err) 213 | } 214 | fmt.Println(sf.Format("User: \"{0}\" successfully updated", user.GetUsername(), params)) 215 | 216 | case operations.RealmResource: 217 | var newRealm data.Realm 218 | if err := json.Unmarshal(value, &newRealm); err != nil { 219 | log.Fatalf("json.Unmarshal failed: %s", err) 220 | } 221 | if err := manager.UpdateRealm(resourceId, newRealm); err != nil { 222 | log.Fatalf("UpdateRealm failed: %s", err) 223 | } 224 | fmt.Println(sf.Format("Realm: \"{0}\" successfully updated", newRealm.Name)) 225 | case operations.UserFederationConfigResource: 226 | var userFederationServiceConfig data.UserFederationServiceConfig 227 | if err := json.Unmarshal(value, &userFederationServiceConfig); err != nil { 228 | log.Fatalf("json.Unmarshal failed: %s", err) 229 | } 230 | if err := manager.UpdateUserFederationConfig(params, resourceId, userFederationServiceConfig); err != nil { 231 | log.Fatalf("UpdateUserFederationConfig failed: %s", err) 232 | } 233 | fmt.Println(sf.Format("User federation service config: \"{0}\" successfully updated", userFederationServiceConfig.Name, params)) 234 | } 235 | 236 | return 237 | case operations.ChangePassword: 238 | switch resource { 239 | case operations.UserResource: 240 | fallthrough 241 | case "": 242 | if params == "" { 243 | log.Fatalf("Not specified Params") 244 | } 245 | if resourceId == "" { 246 | log.Fatalf("Not specified Resource_id") 247 | } 248 | // TODO(SIA) Moving password verification to another location 249 | if len(value) < 8 { 250 | log.Fatalf("Password length must be greater than 7") 251 | } 252 | password := string(value) 253 | passwordManager := manager.(PasswordManager) 254 | if err := passwordManager.SetPassword(params, resourceId, password); err != nil { 255 | log.Fatalf("SetPassword failed: %s", err) 256 | } 257 | fmt.Printf("Password successfully changed") 258 | 259 | default: 260 | log.Fatalf("Bad Resource") 261 | } 262 | 263 | return 264 | case operations.ResetPassword: 265 | switch resource { 266 | case operations.UserResource: 267 | fallthrough 268 | case "": 269 | if params == "" { 270 | log.Fatalf("Not specified Params") 271 | } 272 | if resourceId == "" { 273 | log.Fatalf("Not specified ResourceId") 274 | } 275 | password := getRandPassword() 276 | passwordManager := manager.(PasswordManager) 277 | if err := passwordManager.SetPassword(params, resourceId, password); err != nil { 278 | log.Fatalf("SetPassword failed: %s", err) 279 | } 280 | fmt.Printf("New password: %s", password) 281 | 282 | default: 283 | log.Fatalf("Bad Resource") 284 | } 285 | 286 | return 287 | default: 288 | log.Fatalf("Bad Operation") 289 | } 290 | } 291 | 292 | func getRandPassword() string { 293 | // TODO(SIA) Move password generation to another location 294 | randomBytes := make([]byte, 32) 295 | _, err := rand.Read(randomBytes) 296 | if err != nil { 297 | log.Fatalf("rand.Read failed: %s", err) 298 | } 299 | str := base32.StdEncoding.EncodeToString(randomBytes) 300 | const length = 8 301 | password := str[:length] 302 | return password 303 | } 304 | -------------------------------------------------------------------------------- /api/admin/cli/operations/operations.go: -------------------------------------------------------------------------------- 1 | package operations 2 | 3 | type ResourceType string 4 | 5 | const ( 6 | RealmResource ResourceType = "realm" 7 | ClientResource ResourceType = "client" 8 | UserResource ResourceType = "user" 9 | UserFederationConfigResource ResourceType = "user_federation" 10 | ) 11 | 12 | type OperationType string 13 | 14 | const ( 15 | GetOperation OperationType = "get" 16 | CreateOperation OperationType = "create" 17 | DeleteOperation OperationType = "delete" 18 | UpdateOperation OperationType = "update" 19 | ChangePassword OperationType = "change_password" 20 | ResetPassword OperationType = "reset_password" 21 | ) 22 | -------------------------------------------------------------------------------- /api/rest/common.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | const ( 9 | authorizationHeader = "Authorization" 10 | ) 11 | 12 | type tokenType string 13 | 14 | const ( 15 | BearerToken tokenType = "Bearer" 16 | RefreshToken tokenType = "Refresh" 17 | ) 18 | 19 | // beforeHandle 20 | /* This function prepare response headers prior to response handle. It sets content-type and CORS headers. 21 | * Parameters: 22 | * - respWriter - gorilla/mux response writer 23 | * Returns nothing 24 | */ 25 | func beforeHandle(respWriter *http.ResponseWriter) { 26 | (*respWriter).Header().Set("Content-Type", "application/json") 27 | (*respWriter).Header().Set("Accept", "application/json") 28 | } 29 | 30 | // afterHandle 31 | /* This function finalize response handle: serialize (json) and write object and set status code. If error occur during object serialization status code sets to 500 32 | * Parameters: 33 | * - respWriter - gorilla/mux response writer 34 | * - statusCode - http response status 35 | * - data - object (json) could be empty 36 | * Returns nothing 37 | */ 38 | func afterHandle(respWriter *http.ResponseWriter, statusCode int, data interface{}) { 39 | (*respWriter).WriteHeader(statusCode) 40 | if data != nil { 41 | err := json.NewEncoder(*respWriter).Encode(data) 42 | if err != nil { 43 | (*respWriter).WriteHeader(500) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/rest/context.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "github.com/wissance/Ferrum/data" 5 | "github.com/wissance/Ferrum/logging" 6 | "github.com/wissance/Ferrum/managers" 7 | "github.com/wissance/Ferrum/services" 8 | ) 9 | 10 | // WebApiContext is a central Application logic processor manages from Web via HTTP/HTTPS 11 | type WebApiContext struct { 12 | Address string 13 | Schema string 14 | DataProvider *managers.DataContext 15 | AuthDefs *data.AuthenticationDefs 16 | Security *services.SecurityService 17 | TokenGenerator *services.JwtGenerator 18 | Logger *logging.AppLogger 19 | } 20 | -------------------------------------------------------------------------------- /api/rest/utilities.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "unicode" 5 | ) 6 | 7 | // Checks that the string consists of allowed characters. 8 | func Validate(str string) bool { 9 | if str == "" { 10 | return false 11 | } 12 | isValidRune := func(r rune) bool { 13 | if r == '_' || r == '-' { 14 | return true 15 | } 16 | if !unicode.IsLetter(r) && !unicode.IsDigit(r) { 17 | return false 18 | } 19 | return true 20 | } 21 | 22 | runes := []rune(str) 23 | if !isValidRune(runes[0]) { 24 | return false 25 | } 26 | for i := 1; i < len(runes); i++ { 27 | if !isValidRune(runes[i]) { 28 | return false 29 | } 30 | if runes[i] == '-' && runes[i-1] == '-' { 31 | return false 32 | } 33 | } 34 | return true 35 | } 36 | -------------------------------------------------------------------------------- /api/rest/utilities_test.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | sf "github.com/wissance/stringFormatter" 10 | ) 11 | 12 | var cases = []struct { 13 | name string 14 | value string 15 | expected bool 16 | }{ 17 | {name: "correct", value: "GsFGdfgdfgdfg", expected: true}, 18 | {name: "cyrillic test", value: "вапфрЫВАхиа", expected: true}, 19 | {name: "number and test", value: "sdfg12515", expected: true}, 20 | {name: "with underscore", value: "dfglq_sfdg_as", expected: true}, 21 | {name: "with dash", value: "dfgdf-qwlwer-qwel", expected: true}, 22 | {name: "with dash start", value: "-dfgdfqwlwerqwel", expected: true}, 23 | 24 | {name: "empty string", value: "", expected: false}, 25 | {name: "with space", value: "sdf sdf", expected: false}, 26 | {name: "with double dash", value: "fdsdf--qlwqk", expected: false}, 27 | {name: "with double dash start", value: "--fdsdfqlwqk", expected: false}, 28 | {name: "with double dash finish", value: "fdsdfqlwqk--", expected: false}, 29 | {name: "with slash", value: "kddfg/asd", expected: false}, 30 | {name: "with backslash", value: `dfgdfg \n a\sd`, expected: false}, 31 | {name: "QUOTATION MARK", value: "0\"", expected: false}, 32 | {name: "QUOTATION MARK", value: `"`, expected: false}, 33 | {name: "backslash", value: "\\", expected: false}, 34 | {name: "slash", value: "/", expected: false}, 35 | {name: "with semicolon;", value: "sdfsdf;", expected: false}, 36 | } 37 | 38 | func TestValidate(t *testing.T) { 39 | for _, tc := range cases { 40 | tc := tc 41 | t.Run(tc.name, func(t *testing.T) { 42 | t.Parallel() 43 | assert.Equal(t, tc.expected, Validate(tc.value), sf.Format("INPUT: {0}", tc.value)) 44 | }) 45 | } 46 | } 47 | 48 | func FuzzValidate(f *testing.F) { 49 | for _, tc := range cases { 50 | f.Add(tc.value) 51 | } 52 | f.Fuzz(func(t *testing.T, input string) { 53 | isValid := Validate(input) 54 | forbiddenSymbols := []rune{'"', '\'', '%', '/', '\\'} 55 | if isContainsOne(input, forbiddenSymbols...) { 56 | require.False(t, isValid, sf.Format("INPUT: {0}", input)) 57 | } 58 | if strings.Contains(input, "--") { 59 | require.False(t, isValid, sf.Format("INPUT: {0}", input)) 60 | } 61 | }) 62 | } 63 | 64 | // Returns true if at least one rune is contained 65 | func isContainsOne(input string, args ...rune) bool { 66 | for _, r := range args { 67 | isContains := strings.ContainsRune(input, r) 68 | if isContains { 69 | return true 70 | } 71 | } 72 | return false 73 | } 74 | -------------------------------------------------------------------------------- /api/routing_fuzzing_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "regexp" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/wissance/Ferrum/application" 15 | "github.com/wissance/Ferrum/config" 16 | "github.com/wissance/Ferrum/data" 17 | "github.com/wissance/Ferrum/dto" 18 | "github.com/wissance/Ferrum/utils/encoding" 19 | sf "github.com/wissance/stringFormatter" 20 | 21 | "github.com/stretchr/testify/assert" 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | const ( 26 | testAccessTokenExpiration = 10 27 | testRefreshTokenExpiration = 5 28 | testRealm1 = "testrealm1" 29 | testClient1 = "testclient1" 30 | testClient1Secret = "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz" 31 | ) 32 | 33 | var ( 34 | testSalt = "salt" 35 | encoder = encoding.NewPasswordJsonEncoder(testSalt) 36 | testHashedPassowrd = encoder.GetB64PasswordHash("1234567890") 37 | testKey = []byte("qwerty1234567890") 38 | testServerData = data.ServerData{ 39 | Realms: []data.Realm{ 40 | { 41 | Name: testRealm1, TokenExpiration: testAccessTokenExpiration, RefreshTokenExpiration: testRefreshTokenExpiration, 42 | Clients: []data.Client{ 43 | {Name: testClient1, Type: data.Confidential, Auth: data.Authentication{ 44 | Type: data.ClientIdAndSecrets, 45 | Value: testClient1Secret, 46 | }}, 47 | }, 48 | Users: []interface{}{ 49 | map[string]interface{}{ 50 | "info": map[string]interface{}{ 51 | "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 52 | "name": "vano", "preferred_username": "vano", 53 | "given_name": "vano ivanov", "family_name": "ivanov", "email_verified": true, 54 | }, 55 | "credentials": map[string]interface{}{"password": testHashedPassowrd}, 56 | }, 57 | }, 58 | PasswordSalt: testSalt, 59 | }, 60 | }, 61 | } 62 | ) 63 | 64 | var ( 65 | loggingConfig = config.LoggingConfig{Level: "info", Appenders: []config.AppenderConfig{{Level: "info", Type: config.Console}}} 66 | httpAppConfig = config.AppConfig{ 67 | ServerCfg: config.ServerConfig{Schema: config.HTTP, Address: "127.0.0.1", Port: 8284}, 68 | Logging: loggingConfig, DataSource: config.DataSourceConfig{Type: config.FILE}, 69 | } 70 | ) 71 | 72 | func FuzzTestIssueNewTokenWithWrongClientId(f *testing.F) { 73 | f.Add("\x00testclient1") 74 | f.Add("\x00test_client_1") 75 | f.Add("") 76 | f.Add("0") 77 | f.Add("00") 78 | 79 | f.Fuzz(func(t *testing.T, clientId string) { 80 | initApp(t) 81 | issueNewToken(t, clientId, testClient1Secret, "vano", "1234567890", 400) 82 | }) 83 | } 84 | 85 | func FuzzTestIssueNewTokenWithWrongClientSecret(f *testing.F) { 86 | f.Add("\x00fb6Z4RsOadVycQoeQiN57xpu8w8wplYz") 87 | f.Add("fb6Z4RsOadVycQoeQiN57xpu8w8wplYz_!") 88 | f.Add("") 89 | 90 | f.Fuzz(func(t *testing.T, clientSecret string) { 91 | initApp(t) 92 | issueNewToken(t, testClient1, clientSecret, "vano", "1234567890", 400) 93 | }) 94 | } 95 | 96 | func FuzzTestIssueNewTokenWithWrongUsername(f *testing.F) { 97 | f.Add("\x00vano") 98 | f.Add("!") 99 | f.Add("") 100 | 101 | f.Fuzz(func(t *testing.T, username string) { 102 | initApp(t) 103 | issueNewToken(t, testClient1, testClient1Secret, username, "1234567890", 401) 104 | }) 105 | } 106 | 107 | func FuzzTestIssueNewTokenWithWrongPassword(f *testing.F) { 108 | f.Add("\x001234567890") 109 | f.Add("!") 110 | f.Add("") 111 | 112 | f.Fuzz(func(t *testing.T, password string) { 113 | initApp(t) 114 | issueNewToken(t, testClient1, testClient1Secret, "vano", password, 401) 115 | }) 116 | } 117 | 118 | func FuzzTestIntrospectTokenWithWrongClientId(f *testing.F) { 119 | f.Add("\x001234567890") 120 | f.Add("!") 121 | f.Add("") 122 | 123 | f.Fuzz(func(t *testing.T, clientId string) { 124 | initApp(t) 125 | token := getToken(t) 126 | checkIntrospectToken(t, token.AccessToken, clientId, testClient1Secret, testRealm1, 401) 127 | }) 128 | } 129 | 130 | func FuzzTestIntrospectTokenWithWrongSecret(f *testing.F) { 131 | f.Add("\x001234567890") 132 | f.Add("!") 133 | f.Add("") 134 | 135 | f.Fuzz(func(t *testing.T, clientSecret string) { 136 | initApp(t) 137 | token := getToken(t) 138 | checkIntrospectToken(t, token.AccessToken, testClient1, clientSecret, testRealm1, 401) 139 | }) 140 | } 141 | 142 | func FuzzTestIntrospectTokenWithWrongToken(f *testing.F) { 143 | f.Add(" ") 144 | f.Add("\x001234567890") 145 | f.Add("!") 146 | f.Add("") 147 | 148 | f.Fuzz(func(t *testing.T, token string) { 149 | initApp(t) 150 | checkIntrospectToken(t, token, testClient1, testClient1Secret, testRealm1, 401) 151 | }) 152 | } 153 | 154 | func FuzzTestRefreshTokenWithWrongToken(f *testing.F) { 155 | f.Add("\x00testclient1") 156 | f.Add("\x00test_client_1") 157 | f.Add("") 158 | f.Add("0") 159 | f.Add("00") 160 | 161 | f.Fuzz(func(t *testing.T, token string) { 162 | initApp(t) 163 | refreshToken(t, testClient1, testClient1Secret, token, 401) 164 | }) 165 | } 166 | 167 | func FuzzTestGetUserInfoWithWrongToken(f *testing.F) { 168 | f.Add("\t") 169 | f.Add("00") 170 | f.Add(" ") 171 | f.Add("\n\n") 172 | 173 | f.Fuzz(func(t *testing.T, token string) { 174 | initApp(t) 175 | expectedStatusCode := 401 176 | if !isTokenValid(t, token) || len(token) == 0 { 177 | expectedStatusCode = 400 178 | } 179 | userInfoUrlTemplate := "{0}/auth/realms/{1}/protocol/openid-connect/userinfo/" 180 | doRequest( 181 | t, "GET", userInfoUrlTemplate, testRealm1, nil, 182 | expectedStatusCode, map[string]string{"Authorization": "Bearer " + token}, 183 | ) 184 | }) 185 | } 186 | 187 | func initApp(t *testing.T) application.AppRunner { 188 | t.Helper() 189 | app := application.CreateAppWithData(&httpAppConfig, &testServerData, testKey, true) 190 | t.Cleanup(func() { 191 | _, err := app.Stop(context.Background()) 192 | require.NoError(t, err) 193 | }) 194 | res, err := app.Init() 195 | assert.True(t, res) 196 | assert.Nil(t, err) 197 | res, err = app.Start() 198 | assert.True(t, res) 199 | assert.Nil(t, err) 200 | return app 201 | } 202 | 203 | func issueNewToken(t *testing.T, clientId, clientSecret, username, password string, expectedStatus int) *http.Response { 204 | t.Helper() 205 | urlTemplate := "{0}/auth/realms/{1}/protocol/openid-connect/token" 206 | issueNewTokenUrl := makeUrl(t, urlTemplate, testRealm1) 207 | getTokenData := setGetTokenFormData(clientId, clientSecret, "password", username, password, "") 208 | return doPostForm(t, issueNewTokenUrl, getTokenData, expectedStatus) 209 | } 210 | 211 | func refreshToken(t *testing.T, clientId, clientSecret, refreshToken string, expectedStatus int) *http.Response { 212 | t.Helper() 213 | urlTemplate := "{0}/realms/{1}/protocol/openid-connect/token" 214 | refreshTokenUrl := makeUrl(t, urlTemplate, testRealm1) 215 | getTokenData := setGetTokenFormData(clientId, clientSecret, "refresh_token", "", "", refreshToken) 216 | return doPostForm(t, refreshTokenUrl, getTokenData, expectedStatus) 217 | } 218 | 219 | func setGetTokenFormData(clientId, clientSecret, grantType, username, password, refreshToken string) url.Values { 220 | getTokenData := url.Values{} 221 | getTokenData.Set("client_id", clientId) 222 | getTokenData.Set("client_secret", clientSecret) 223 | getTokenData.Set("scope", "profile") 224 | getTokenData.Set("grant_type", grantType) 225 | getTokenData.Set("username", username) 226 | getTokenData.Set("password", password) 227 | getTokenData.Set("refresh_token", refreshToken) 228 | return getTokenData 229 | } 230 | 231 | func doPostForm(t *testing.T, reqUrl string, urlData url.Values, expectedStatus int) *http.Response { 232 | t.Helper() 233 | response, err := http.PostForm(reqUrl, urlData) 234 | require.NoError(t, err) 235 | if response != nil { 236 | require.Equal(t, response.StatusCode, expectedStatus) 237 | } 238 | return response 239 | } 240 | 241 | func doRequest(t *testing.T, method, urlTemplate, realm string, 242 | formData *url.Values, expectedStatus int, headers map[string]string, 243 | ) { 244 | var err error 245 | var request *http.Request 246 | reqUrl := makeUrl(t, urlTemplate, realm) 247 | if formData != nil { 248 | request, err = http.NewRequest(method, reqUrl, strings.NewReader(formData.Encode())) 249 | } else { 250 | request, err = http.NewRequest(method, reqUrl, nil) 251 | } 252 | 253 | client := http.Client{} 254 | assert.NoError(t, err) 255 | 256 | for key, value := range headers { 257 | request.Header.Set(key, value) 258 | } 259 | 260 | response, _ := client.Do(request) 261 | if response != nil { 262 | assert.Equal(t, expectedStatus, response.StatusCode) 263 | } 264 | } 265 | 266 | func getToken(t *testing.T) dto.Token { 267 | t.Helper() 268 | response := issueNewToken(t, testClient1, testClient1Secret, "vano", "1234567890", 200) 269 | token := getDataFromResponse[dto.Token](t, response) 270 | return token 271 | } 272 | 273 | func checkIntrospectToken( 274 | t *testing.T, token, clientId, clientSecret, realm string, expectedStatus int, 275 | ) { 276 | t.Helper() 277 | urlTemplate := sf.Format("{0}/auth/realms/{1}/protocol/openid-connect/token/introspect") 278 | formData := url.Values{} 279 | formData.Set("token_type_hint", "requesting_party_token") 280 | formData.Set("token", token) 281 | httpBasicAuth := base64.StdEncoding.EncodeToString([]byte(clientId + ":" + clientSecret)) 282 | 283 | headers := map[string]string{ 284 | "Authorization": "Basic " + httpBasicAuth, 285 | "Content-Type": "application/x-www-form-urlencoded", 286 | } 287 | 288 | doRequest(t, "POST", urlTemplate, realm, &formData, expectedStatus, headers) 289 | } 290 | 291 | func makeUrl(t *testing.T, urlTemplate, realm string) string { 292 | t.Helper() 293 | serverAddress := sf.Format("{0}:{1}", httpAppConfig.ServerCfg.Address, httpAppConfig.ServerCfg.Port) 294 | baseUrl := sf.Format("{0}://{1}", httpAppConfig.ServerCfg.Schema, serverAddress) 295 | url_ := sf.Format(urlTemplate, baseUrl, realm) 296 | return url_ 297 | } 298 | 299 | func getDataFromResponse[TR dto.Token | dto.ErrorDetails](t *testing.T, response *http.Response) TR { 300 | t.Helper() 301 | responseBody, err := io.ReadAll(response.Body) 302 | assert.Nil(t, err) 303 | var result TR 304 | err = json.Unmarshal(responseBody, &result) 305 | assert.Nil(t, err) 306 | return result 307 | } 308 | 309 | func isTokenValid(t *testing.T, token string) bool { 310 | // Checking that the token doesn't contains space characters only. 311 | // If yes, then the token is not valid - the expected status code is 400. Otherwise - 401. 312 | t.Helper() 313 | pattern := "[ \n\t]+" 314 | match, _ := regexp.MatchString(pattern, token) 315 | return !match 316 | } 317 | -------------------------------------------------------------------------------- /application/application_runner.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/wissance/Ferrum/logging" 7 | ) 8 | 9 | /*type AppContextBase struct { 10 | Context context.Context 11 | }*/ 12 | 13 | // AppRunner interface that allows to manipulate application 14 | type AppRunner interface { 15 | // Start this function starts initialized application (must be called after Init) 16 | Start() (bool, error) 17 | // Stop function to stop application 18 | Stop(ctx context.Context) (bool, error) 19 | // Init function initializes application components 20 | Init() (bool, error) 21 | // GetLogger function that required after app initialized all components to log some additional information about application stop 22 | GetLogger() *logging.AppLogger 23 | } 24 | -------------------------------------------------------------------------------- /certs/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIBrDCCATKgAwIBAgIUR9GeyLmfLmKB8GqocWnUGL9FjZEwCgYIKoZIzj0EAwIw 3 | DTELMAkGA1UEBhMCUlUwHhcNMjQwMzA2MTYyODE1WhcNMzQwMzA0MTYyODE1WjAN 4 | MQswCQYDVQQGEwJSVTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLPdymXCG5BVJ5I0 5 | W1U945J76TXT/6LeUEjraAsgTvkJRIqnrL04r2/jIs+aiaT2OCcUcbsVJn0bWIO6 6 | yELFnuXGpAzCE6haznySGeU16uhO59YD5ltDCT40tp0rGx4XBqNTMFEwHQYDVR0O 7 | BBYEFKLkNdefz7AAZ8VkQxQwIQukGaunMB8GA1UdIwQYMBaAFKLkNdefz7AAZ8Vk 8 | QxQwIQukGaunMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDaAAwZQIwGQ4/ 9 | qKKuG966RgaPW8O7qyAZixamJQiD+M53KXVJyipspy8h7KmB+JlzW3UZF+saAjEA 10 | 4XrEe5eituhC5X/QC4qGRXFLbJ9AINedBoWsGFXgwUiEuHnsF2sKoEDzaM70kj1d 11 | -----END CERTIFICATE----- 12 | -------------------------------------------------------------------------------- /certs/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PARAMETERS----- 2 | BgUrgQQAIg== 3 | -----END EC PARAMETERS----- 4 | -----BEGIN EC PRIVATE KEY----- 5 | MIGkAgEBBDBSR52YdtbYasS4PLm4dYJI4UoA+EXKjD/fGzaQq+gyGafu42/Bp9A4 6 | /CN/T+lUVvugBwYFK4EEACKhZANiAASz3cplwhuQVSeSNFtVPeOSe+k10/+i3lBI 7 | 62gLIE75CUSKp6y9OK9v4yLPmomk9jgnFHG7FSZ9G1iDushCxZ7lxqQMwhOoWs58 8 | khnlNeroTufWA+ZbQwk+NLadKxseFwY= 9 | -----END EC PRIVATE KEY----- 10 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "schema": "http", 4 | "address": "localhost", 5 | "port": 8182, 6 | "secret_file": "./keyfile", 7 | "shutdown_timeout": 30 8 | }, 9 | "logging": { 10 | "level": "debug", 11 | "appenders": [ 12 | { 13 | "type": "rolling_file", 14 | "enabled": true, 15 | "level": "debug", 16 | "destination": { 17 | "file": "./logs/ferrum.log", 18 | "max_size": 100, 19 | "max_age": 5, 20 | "max_backups": 5, 21 | "local_time": true 22 | } 23 | }, 24 | { 25 | "type": "console", 26 | "enabled": true, 27 | "level": "debug" 28 | } 29 | ], 30 | "http_log": true, 31 | "http_console_out": true 32 | }, 33 | "data_source": { 34 | "type": "file", 35 | "source": "./data.json" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/app_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | gce "github.com/wissance/go-config-extender" 5 | "os" 6 | ) 7 | 8 | const ( 9 | serverValidationErrExitCode = 567 10 | dataSourceValidationErrExitCode = 568 11 | loggingSystemValidationErrExitCode = 569 12 | ) 13 | 14 | type AppConfig struct { 15 | ServerCfg ServerConfig `json:"server"` 16 | DataSource DataSourceConfig `json:"data_source"` 17 | Logging LoggingConfig `json:"logging"` 18 | } 19 | 20 | func ReadAppConfig(pathToConfig string) (*AppConfig, error) { 21 | cfg, err := gce.LoadJSONConfigWithEnvOverride[AppConfig](pathToConfig) 22 | if err != nil { 23 | return nil, err 24 | } 25 | cfg.Validate() 26 | return &cfg, nil 27 | } 28 | 29 | func (cfg *AppConfig) Validate() { 30 | serverCfgValidationErr := cfg.ServerCfg.Validate() 31 | if serverCfgValidationErr != nil { 32 | println(serverCfgValidationErr.Error()) 33 | os.Exit(serverValidationErrExitCode) 34 | } 35 | 36 | dataSourceCfgValidationErr := cfg.DataSource.Validate() 37 | if dataSourceCfgValidationErr != nil { 38 | println(dataSourceCfgValidationErr.Error()) 39 | os.Exit(dataSourceValidationErrExitCode) 40 | } 41 | 42 | loggingSystemCfgValidationErr := cfg.Logging.Validate() 43 | if loggingSystemCfgValidationErr != nil { 44 | println(loggingSystemCfgValidationErr.Error()) 45 | os.Exit(loggingSystemValidationErrExitCode) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/app_config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/stretchr/testify/assert" 6 | "io/ioutil" 7 | "path" 8 | "testing" 9 | ) 10 | 11 | func TestValidateAppConfigWithRedisDataSourceCfg(t *testing.T) { 12 | testCases := []struct { 13 | name string 14 | cfgFile string 15 | expectedSource string 16 | expectedCredentials *CredentialsConfig 17 | dbNumber string 18 | }{ 19 | { 20 | name: "MinimalValidAppCfgWithRedis", 21 | cfgFile: path.Join("test_configs", "valid_config_w_min_redis.json"), 22 | expectedSource: "localhost:6380", 23 | expectedCredentials: &CredentialsConfig{ 24 | Username: "dbmn", 25 | Password: "123", 26 | }, 27 | dbNumber: "12", 28 | }, 29 | } 30 | 31 | for _, tc := range testCases { 32 | t.Run(tc.name, func(t *testing.T) { 33 | fileData, err := ioutil.ReadFile(tc.cfgFile) 34 | assert.NoError(t, err) 35 | appConfig := AppConfig{} 36 | err = json.Unmarshal(fileData, &appConfig) 37 | assert.NoError(t, err) 38 | // check values itself 39 | checkDataSourceValues(t, REDIS, tc.expectedSource, tc.expectedCredentials, 40 | map[DataSourceConnOption]string{ 41 | DbNumber: tc.dbNumber, 42 | }, appConfig.DataSource) 43 | err = appConfig.DataSource.Validate() 44 | assert.NoError(t, err) 45 | }) 46 | } 47 | } 48 | 49 | func checkDataSourceValues(t *testing.T, expectedSourceType DataSourceType, expectedSource string, expectedCredentials *CredentialsConfig, 50 | expectedOptions map[DataSourceConnOption]string, actualCfg DataSourceConfig) { 51 | assert.Equal(t, expectedSourceType, actualCfg.Type) 52 | assert.Equal(t, expectedSource, actualCfg.Source) 53 | if expectedCredentials == nil { 54 | assert.Nil(t, actualCfg.Credentials) 55 | } else { 56 | assert.NotNil(t, actualCfg.Credentials) 57 | assert.Equal(t, expectedCredentials.Username, actualCfg.Credentials.Username) 58 | assert.Equal(t, expectedCredentials.Password, actualCfg.Credentials.Password) 59 | } 60 | assert.Equal(t, len(expectedOptions), len(actualCfg.Options)) 61 | for k, v := range expectedOptions { 62 | av, ok := actualCfg.Options[k] 63 | assert.True(t, ok) 64 | assert.Equal(t, v, av) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /config/credentials_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type CredentialsConfig struct { 4 | Username string `json:"username"` 5 | Password string `json:"password"` 6 | } 7 | -------------------------------------------------------------------------------- /config/data_source_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "github.com/wissance/Ferrum/utils/validators" 6 | sf "github.com/wissance/stringFormatter" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type DataSourceType string 12 | type DataSourceConnOption string 13 | 14 | const ( 15 | FILE DataSourceType = "file" 16 | // MONGODB TODO (UMV): Mongo won't be using for sometime, maybe it will be removed completely 17 | MONGODB DataSourceType = "mongodb" 18 | // REDIS : redis server should be running with dump every write on a disk (AOF) 19 | REDIS DataSourceType = "redis" 20 | ) 21 | 22 | const ( 23 | // DbNumber is a REDIS connection options, here we expect to receive int in a string 24 | DbNumber DataSourceConnOption = "db_number" 25 | // UseTls is a REDIS option to set up tls.Config and use TLS connection, here we expect to receive bool value in a string 26 | UseTls DataSourceConnOption = "use_tls" 27 | // InsecureTls is a REDIS option to set up TLSConfig: &tls.Config{InsecureSkipVerify: true}, here we expect to receive bool value in a string 28 | InsecureTls DataSourceConnOption = "allow_insecure_tls" 29 | // Namespace is a prefix before any key 30 | Namespace DataSourceConnOption = "namespace" 31 | ) 32 | 33 | var ( 34 | SourceISEmpty error = errors.New("field source (path to file or conn str to db) is empty") 35 | ) 36 | 37 | // DataSourceConfig represent source where we can get 38 | /* 39 | * We attempt to provide config that easily could be used with any datasource: 40 | * - json file (simplest RO mode) 41 | * - mongodb (but here we have very simple question how to pass parameters) 42 | * Source contains: 43 | * 1) if Type is FILE - full path to Json File 44 | * 2) if Type is REDIS - redis server address i.e. localhost:6739 45 | * Options are connection options, see - https://www.mongodb.com/docs/drivers/go/current/fundamentals/connection/#std-label-golang-connection-guide 46 | * Here we should have Validator too 47 | * Credentials contains Username && Password could be null id authorization is not required: 48 | * 49 | */ 50 | type DataSourceConfig struct { 51 | Type DataSourceType `json:"type"` 52 | Source string `json:"source"` 53 | Credentials *CredentialsConfig `json:"credentials"` 54 | Options map[DataSourceConnOption]string `json:"options"` 55 | } 56 | 57 | func (cfg *DataSourceConfig) Validate() error { 58 | if len(cfg.Source) == 0 { 59 | return SourceISEmpty 60 | 61 | } 62 | if cfg.Type == MONGODB { 63 | return errors.New("mongodb is not supported ") 64 | } 65 | if cfg.Type == REDIS { 66 | // 1. Check whether source contains address or not 67 | // 1.1 Check format 68 | parts := strings.Split(cfg.Source, ":") 69 | if len(parts) != 2 { 70 | return errors.New("field source for Redis datasource must contain pair IP Address/Domain Name:Port, i.e. 127.0.0.1:6379") 71 | } 72 | // 1.2 73 | // todo(UMV): check IP Address / Domain Name is Valid 74 | _, err := strconv.Atoi(parts[1]) 75 | if err != nil { 76 | return errors.New(sf.Format("second part must be integer value, got parsing error: {0}", err.Error())) 77 | } 78 | // 1.3 Check we have following required fields: dbNumber 79 | dbNumber, ok := cfg.Options[DbNumber] 80 | if !ok { 81 | return errors.New("config must contain \"db_number\" in options") 82 | } 83 | checkResult := validators.IsStrValueOfRequiredType(validators.Integer, &dbNumber) 84 | if !checkResult { 85 | return errors.New("\"db_number\" redis config options must be int value") 86 | } 87 | return nil 88 | } 89 | if cfg.Type == MONGODB { 90 | // validate options values ... 91 | /*allParamValidation := map[string]string{} 92 | for k, v := range cfg.Options { 93 | keyType := MongoDbOptionsTypes[k] 94 | err := cfg.validateParam(&keyType, &v) 95 | if err != nil { 96 | explanation := stringFormatter.Format("Error at MongoDb parameter \"{0}\" validation, reason: {1}", k, err.Error()) 97 | allParamValidation[string(k)] = explanation 98 | } 99 | } 100 | if len(allParamValidation) > 0 { 101 | // todo(UMV): combine && return 102 | 103 | }*/ 104 | return errors.New("MongoDB is not supported") 105 | } 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /config/logs_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Composing structs for unmarshalling. Writer is lumberjack's setup struct. 4 | // It's annotated for JSON out-of-the-box. 5 | // Logrus is for logging level and log output settings. 6 | type AppenderType string 7 | 8 | const ( 9 | RollingFile AppenderType = "rolling_file" 10 | Console AppenderType = "console" 11 | ) 12 | 13 | /*type GlobalConfig struct { 14 | Logging Logging `json:"logging"` 15 | }*/ 16 | 17 | type DestinationConfig struct { 18 | File AppenderType `json:"file"` 19 | BufferSize int `json:"buffer_size"` 20 | MaxSize int `json:"max_size"` 21 | MaxAge int `json:"max_age"` 22 | MaxBackups int `json:"max_backups"` 23 | LocalTime bool `json:"local_time"` 24 | } 25 | 26 | type AppenderConfig struct { 27 | Type AppenderType `json:"type"` 28 | Enabled bool `json:"enabled"` 29 | Level string `json:"level"` 30 | Destination *DestinationConfig `json:"destination"` 31 | } 32 | 33 | type LoggingConfig struct { 34 | Level string `json:"level"` 35 | Appenders []AppenderConfig `json:"appenders"` 36 | ConsoleOutHTTP bool `json:"http_console_out"` 37 | LogHTTP bool `json:"http_log"` 38 | } 39 | 40 | func (cfg *LoggingConfig) Validate() error { 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /config/mongodb_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const ( 4 | OperationTimeout DataSourceConnOption = "timeoutMS" 5 | ConnectionTimeout DataSourceConnOption = "connectTimeoutMS" 6 | ConnectionsPool DataSourceConnOption = "maxPoolSize" 7 | ReplicaSet DataSourceConnOption = "replicaSet" 8 | MaxIdleTime DataSourceConnOption = "maxIdleTimeMS" 9 | SocketTimeout DataSourceConnOption = "socketTimeoutMS" 10 | ServerSelectionTimeout DataSourceConnOption = "serverSelectionTimeoutMS" 11 | HeartbeatFrequency DataSourceConnOption = "heartbeatFrequencyMS" 12 | Tls DataSourceConnOption = "tls" 13 | WriteConcern DataSourceConnOption = "w" 14 | DirectConnection DataSourceConnOption = "directConnection" 15 | ) 16 | -------------------------------------------------------------------------------- /config/redis_options_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "crypto/tls" 4 | 5 | // todo (UMV): probably we don't need RedisConfig, because we are using map 4 this 6 | 7 | // RedisConfig is a simplified redis.Options config 8 | type RedisConfig struct { 9 | Address string `json:"address" example:"localhost:6379"` 10 | Password string `json:"password"` 11 | DbNumber int `json:"db_number"` 12 | Namespace string `json:"namespace"` 13 | // MaxRetries is a number of attempts to 14 | MaxRetries int `json:"max_retries"` 15 | // MinRetryBackoff is a backoff in milliseconds 16 | MinRetryBackoff int `json:"min_retry_backoff"` 17 | MaxRetryBackoff int `json:"max_retry_backoff"` 18 | // go-redis dial timeout option is time, here we simplify config we assume here Seconds as a time value, 0 means no timeout 19 | DialTimeout uint `json:"dial_timeout"` 20 | // go-redis read timeout option is time, here we simplify config we assume here Seconds as a time value, 0 means no timeout 21 | ReadTimeout uint `json:"read_timeout"` 22 | // go-redis write timeout option is time, here we simplify config we assume here Seconds as a time value, 0 means no timeout 23 | WriteTimeout uint `json:"write_timeout"` 24 | PoolSize uint `json:"pool_size"` 25 | // go-redis pool timeout option is time, here we simplify config we assume here Seconds as a time value, 0 means 1 sec to pool timeout 26 | PoolTimeout uint `json:"pool_timeout"` 27 | MinIdleConn int `json:"min_idle_conn"` 28 | MaxIdleConn int `json:"max_idle_conn"` 29 | // default is 30 min (go-redis), 0 disable max timeout (pass -1 to go-redis) 30 | ConnIdleTimeout uint `json:"conn_idle_timeout"` 31 | // by default go-redis do not close idle connections 32 | ConnMaxLifetimeTimeout int `json:"conn_max_lifetime_timeout"` 33 | ReadOnlySlaveEnabled bool `json:"read_only_slave_enabled"` 34 | TlsCfg *tls.Config `json:"tls_cfg"` 35 | } 36 | -------------------------------------------------------------------------------- /config/server_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "time" 7 | 8 | sf "github.com/wissance/stringFormatter" 9 | ) 10 | 11 | type Schema string 12 | 13 | const ( 14 | HTTP Schema = "http" 15 | HTTPS Schema = "https" 16 | ) 17 | 18 | type SecurityConfig struct { 19 | CertificateFile string `json:"certificate_file" example:"./certificates/server.crt"` 20 | KeyFile string `json:"key_file" example:"./certificates/server.key"` 21 | } 22 | 23 | type ServerConfig struct { 24 | Schema Schema `json:"schema" example:"http or https"` 25 | Address string `json:"address" example:"127.0.0.1 or mydomain.com"` 26 | Port int `json:"port" example:"8080"` 27 | Security *SecurityConfig `json:"security"` 28 | SecretFile string `json:"secret_file" example:"./keyfile"` 29 | ShutdownTimeout time.Duration `json:"shutdown_timeout"` 30 | } 31 | 32 | func (cfg *ServerConfig) Validate() error { 33 | if len(cfg.SecretFile) == 0 { 34 | return errors.New("secret file wasn't set") 35 | } 36 | _, err := os.Stat(cfg.SecretFile) 37 | if err != nil && errors.Is(err, os.ErrNotExist) { 38 | return errors.New(sf.Format("secret file on path \"{0}\" does not exists", cfg.SecretFile)) 39 | } 40 | if cfg.Schema == HTTPS { 41 | if cfg.Security == nil { 42 | return errors.New("https schema requires a certs pair (\"security\" property)") 43 | } 44 | 45 | _, keyFileErr := os.Stat(cfg.Security.KeyFile) 46 | if keyFileErr != nil && errors.Is(keyFileErr, os.ErrNotExist) { 47 | return errors.New(sf.Format("Security (certificate) config Key file \"{0}\" does not exists", cfg.Security.KeyFile)) 48 | } 49 | 50 | _, crtFileErr := os.Stat(cfg.Security.CertificateFile) 51 | if crtFileErr != nil && errors.Is(crtFileErr, os.ErrNotExist) { 52 | return errors.New(sf.Format("Security (certificate) config Certificate file \"{0}\" does not exists", cfg.Security.CertificateFile)) 53 | } 54 | } 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /config/test_configs/valid_config_w_min_redis.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "schema": "http", 4 | "address": "localhost", 5 | "port": 8182 6 | }, 7 | "logging": { 8 | "level": "debug", 9 | "appenders": [ 10 | { 11 | "type": "rolling_file", 12 | "enabled": true, 13 | "level": "debug", 14 | "destination": { 15 | "file": "./logs/ferrum.log", 16 | "max_size": 100, 17 | "max_age": 5, 18 | "max_backups": 5, 19 | "local_time": true 20 | } 21 | }, 22 | { 23 | "type": "console", 24 | "enabled": true, 25 | "level": "debug" 26 | } 27 | ], 28 | "http_log": true, 29 | "http_console_out": true 30 | }, 31 | "data_source": { 32 | "type": "redis", 33 | "source": "localhost:6380", 34 | "credentials": { 35 | "username": "dbmn", 36 | "password": "123" 37 | }, 38 | "options": { 39 | "db_number": "12" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /config/validating_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // ValidatingConfig is interface that contains config struct and Validate method to check whether config can be used further or not 4 | type ValidatingConfig interface { 5 | Validate() error 6 | } 7 | -------------------------------------------------------------------------------- /config_docker_w_redis.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "schema": "http", 4 | "address": "0.0.0.0", 5 | "port": 8182, 6 | "secret_file": "./keyfile", 7 | "shutdown_timeout": 30 8 | }, 9 | "logging": { 10 | "level": "debug", 11 | "appenders": [ 12 | { 13 | "type": "rolling_file", 14 | "enabled": true, 15 | "level": "debug", 16 | "destination": { 17 | "file": "./logs/ferrum.log", 18 | "max_size": 100, 19 | "max_age": 5, 20 | "max_backups": 5, 21 | "local_time": true 22 | } 23 | }, 24 | { 25 | "type": "console", 26 | "enabled": true, 27 | "level": "debug" 28 | } 29 | ], 30 | "http_log": true, 31 | "http_console_out": true 32 | }, 33 | "data_source": { 34 | "type": "redis", 35 | "source": "redis:6379", 36 | "credentials": { 37 | "username": "ferrum_db", 38 | "password": "FeRRuM000" 39 | }, 40 | "options": { 41 | "namespace": "ferrum_1", 42 | "db_number": "0" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /config_w_redis.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "schema": "http", 4 | "address": "127.0.0.1", 5 | "port": 8182, 6 | "secret_file": "./keyfile", 7 | "shutdown_timeout": 30 8 | }, 9 | "logging": { 10 | "level": "debug", 11 | "appenders": [ 12 | { 13 | "type": "rolling_file", 14 | "enabled": true, 15 | "level": "debug", 16 | "destination": { 17 | "file": "./logs/ferrum.log", 18 | "max_size": 100, 19 | "max_age": 5, 20 | "max_backups": 5, 21 | "local_time": true 22 | } 23 | }, 24 | { 25 | "type": "console", 26 | "enabled": true, 27 | "level": "debug" 28 | } 29 | ], 30 | "http_log": true, 31 | "http_console_out": true 32 | }, 33 | "data_source": { 34 | "type": "redis", 35 | "source": "127.0.0.1:6379", 36 | "credentials": { 37 | "username": "ferrum_db", 38 | "password": "FeRRuM000" 39 | }, 40 | "options": { 41 | "namespace": "ferrum_1", 42 | "db_number": "0" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /data.json: -------------------------------------------------------------------------------- 1 | { 2 | "realms": [ 3 | { 4 | "name": "myapp", 5 | "token_expiration": 330, 6 | "refresh_expiration": 200, 7 | "password_salt": "1234567890", 8 | "clients": [ 9 | { 10 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5a14", 11 | "name": "test-service-app-client", 12 | "type": "confidential", 13 | "auth": { 14 | "type": 1, 15 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz" 16 | } 17 | } 18 | ], 19 | "users": [ 20 | { 21 | "info": { 22 | "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 23 | "email_verified": false, 24 | "roles": [ 25 | "admin" 26 | ], 27 | "name": "admin sys", 28 | "preferred_username": "admin", 29 | "given_name": "admin", 30 | "family_name": "sys" 31 | }, 32 | "credentials": { 33 | "password": "AcMWCBu5AQDN8IvSRExUUSQq7H3RH6IzsxZJqyIoEmPFtJwGknUUvzet0vhS95hgkrKLNM66v0mUB5xji8zdqA==" 34 | } 35 | }, 36 | { 37 | "info": { 38 | "sub": "8be91328-0f85-408f-966a-fd9a04ce94d9", 39 | "email_verified": false, 40 | "roles": [ 41 | "1stfloor", 42 | "manager" 43 | ], 44 | "name": "ivan ivanov", 45 | "preferred_username": "vano", 46 | "given_name": "ivan", 47 | "family_name": "ivanov" 48 | }, 49 | "credentials": { 50 | "password": "qwerty_user" 51 | } 52 | } 53 | ] 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /data/authentication.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | type AuthenticationType int 4 | 5 | // ClientIdAndSecrets AuthenticationType represents Confidential Clients 6 | const ( 7 | ClientIdAndSecrets AuthenticationType = 1 8 | ) 9 | 10 | // Authentication struct for Clients authentication data, for ClientIdAndSecrets Value stores ClientSecret 11 | type Authentication struct { 12 | Type AuthenticationType 13 | Value string 14 | Attributes interface{} 15 | } 16 | -------------------------------------------------------------------------------- /data/authentication_defs.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | type AuthenticationDefs struct { 4 | SupportedGrantTypes []string 5 | SupportedResponseTypes []string 6 | SupportedResponses []string 7 | SupportedScopes []string 8 | SupportedClaimTypes []string 9 | SupportedClaims []string 10 | } 11 | -------------------------------------------------------------------------------- /data/client.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | // ClientType is type of client security, Confidential clients must provide ClientSecret 8 | type ClientType string 9 | 10 | const ( 11 | Public ClientType = "public" 12 | Confidential ClientType = "confidential" 13 | ) 14 | 15 | // Client is a realm client, represents an application nad set of rules for interacting with Authorization server 16 | type Client struct { 17 | Type ClientType 18 | ID uuid.UUID 19 | Name string 20 | Auth Authentication 21 | } 22 | -------------------------------------------------------------------------------- /data/keycloak_user.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/google/uuid" 8 | "github.com/ohler55/ojg/jp" 9 | "github.com/wissance/Ferrum/utils/encoding" 10 | ) 11 | 12 | const ( 13 | pathToPassword = "credentials.password" 14 | ) 15 | 16 | // KeyCloakUser this structure is for user data that looks similar to KeyCloak, Users in Keycloak have info field with preferred_username and sub 17 | // and others fields, Ferrum users have credentials built-in in user (temporary it stores in non encrypted mode) 18 | type KeyCloakUser struct { 19 | rawData interface{} 20 | jsonRawData string 21 | } 22 | 23 | // CreateUser function creates User interface instance (KeyCloakUser) from raw json 24 | /* Function create User instance from any json (interface{}) 25 | * Parameters: 26 | * - rawData - any json 27 | * Return: instance of User as KeyCloakUser 28 | */ 29 | func CreateUser(rawData interface{}, encoder *encoding.PasswordJsonEncoder) User { 30 | jsonData, _ := json.Marshal(&rawData) 31 | kcUser := &KeyCloakUser{rawData: rawData, jsonRawData: string(jsonData)} 32 | password := getPathStringValue[string](kcUser.rawData, pathToPassword) 33 | if encoder != nil { 34 | // todo(UMV): handle CreateUser errors in the future 35 | _ = kcUser.SetPassword(password, encoder) 36 | } 37 | user := User(kcUser) 38 | return user 39 | } 40 | 41 | // GetUsername returns username as it stores in KeyCloak 42 | /* this function use internal map to navigate over info.preferred_username keys, the last one key is a login key 43 | * We are expecting that username is unique in the Realm 44 | * Parameters: no 45 | * Returns: username 46 | */ 47 | func (user *KeyCloakUser) GetUsername() string { 48 | return getPathStringValue[string](user.rawData, "info.preferred_username") 49 | } 50 | 51 | // GetPasswordHash returns hash of password 52 | /* this function use internal map to navigate over credentials.password keys to retrieve a hash of password 53 | * Parameters: no 54 | * Returns: hash of password 55 | */ 56 | // todo(UMV): we should consider case when User is External 57 | func (user *KeyCloakUser) GetPasswordHash() string { 58 | password := getPathStringValue[string](user.rawData, pathToPassword) 59 | return password 60 | } 61 | 62 | // SetPassword 63 | /* this function changes a raw password to its hash in the user's rawData and jsonRawData and sets it 64 | * Parameters: 65 | * - password - new password 66 | * - encoder - encoder object with salt and hasher 67 | */ 68 | func (user *KeyCloakUser) SetPassword(password string, encoder *encoding.PasswordJsonEncoder) error { 69 | hashed := encoder.GetB64PasswordHash(password) 70 | if err := setPathStringValue(user.rawData, pathToPassword, hashed); err != nil { 71 | return err 72 | } 73 | jsonData, _ := json.Marshal(&user.rawData) 74 | user.jsonRawData = string(jsonData) 75 | return nil 76 | } 77 | 78 | // GetId returns unique user identifier 79 | /* this function use internal map to navigate over info.sun keys to retrieve a user id 80 | * Parameters: no 81 | * Returns: user id 82 | */ 83 | func (user *KeyCloakUser) GetId() uuid.UUID { 84 | idStrValue := getPathStringValue[string](user.rawData, "info.sub") 85 | id, err := uuid.Parse(idStrValue) 86 | // nolint staticcheck 87 | if err != nil { 88 | // todo(UMV): think what to do here, return error! 89 | } 90 | return id 91 | } 92 | 93 | // GetUserInfo returns Json with all non-confidential user data as KeyCloak do 94 | /* this function use internal map to navigate over key info ant retrieve all public userinfo 95 | * Parameters: no 96 | * Returns: user info 97 | */ 98 | func (user *KeyCloakUser) GetUserInfo() interface{} { 99 | result := getPathStringValue[interface{}](user.rawData, "info") 100 | return result 101 | } 102 | 103 | func (user *KeyCloakUser) GetRawData() interface{} { 104 | return user.rawData 105 | } 106 | 107 | func (user *KeyCloakUser) GetJsonString() string { 108 | return user.jsonRawData 109 | } 110 | 111 | // IsFederatedUser returns bool if user storing externally, if user is external, password can't be stored in storage 112 | /* this function determines whether user stores outside the database i.e. in ActiveDirectory or other systems 113 | * navigation property for this federation.name 114 | * Parameters: no 115 | */ 116 | func (user *KeyCloakUser) IsFederatedUser() bool { 117 | result := getPathStringValue[string](user.rawData, "federation.name") 118 | return len(result) > 0 119 | } 120 | 121 | func (user *KeyCloakUser) GetFederationId() string { 122 | result := getPathStringValue[string](user.rawData, "federation.name") 123 | return result 124 | } 125 | 126 | // getPathStringValue is a generic function to get actually map by key, key represents as a jsonpath navigation property 127 | /* this function uses json path to navigate over nested maps and return any required type 128 | * Parameters: 129 | * - rawData - json object 130 | * - path - json path to retrieve part of json with specified type (T) 131 | * Returns: part of json 132 | */ 133 | func getPathStringValue[T any](rawData interface{}, path string) T { 134 | var result T 135 | mask, err := jp.ParseString(path) 136 | // nolint staticcheck 137 | if err != nil { 138 | // todo(UMV): log and think what to do ... 139 | } 140 | res := mask.Get(rawData) 141 | if len(res) == 1 { 142 | result = res[0].(T) 143 | } 144 | return result 145 | } 146 | 147 | // setPathStringValue is a function to search data by path and set data by key, key represents as a jsonpath navigation property 148 | /* this function uses json path to navigate over nested maps and set data 149 | * Parameters: 150 | * - rawData - json object 151 | * - path - json path to retrieve part of json 152 | * - value - value to be set to rawData 153 | */ 154 | func setPathStringValue(rawData interface{}, path string, value string) error { 155 | mask, err := jp.ParseString(path) 156 | if err != nil { 157 | return fmt.Errorf("jp.ParseString failed: %w", err) 158 | } 159 | if err := mask.Set(rawData, value); err != nil { 160 | return fmt.Errorf("jp.Set failed: %w", err) 161 | } 162 | return nil 163 | } 164 | -------------------------------------------------------------------------------- /data/keycloak_user_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/wissance/Ferrum/utils/encoding" 9 | sf "github.com/wissance/stringFormatter" 10 | ) 11 | 12 | func TestInitUserWithJsonAndCheck(t *testing.T) { 13 | testCases := []struct { 14 | name string 15 | userName string 16 | preferredUsername string 17 | isFederated bool 18 | userTemplate string 19 | federationId string 20 | }{ 21 | { 22 | name: "simple_user", userName: "admin", preferredUsername: "Administrator", isFederated: false, 23 | userTemplate: `{"info":{"name":"{0}", "preferred_username": "{1}"}}`, 24 | }, 25 | { 26 | name: "federated_user", userName: `m.ushakov`, preferredUsername: "m.ushakov", isFederated: true, federationId: "Wissance_test_domain", 27 | userTemplate: `{"info":{"name":"{0}", "preferred_username": "{1}"}, "federation":{"name":"Wissance_test_domain"}}`, 28 | }, 29 | { 30 | name: "federated_user", userName: `root`, preferredUsername: "root", isFederated: false, 31 | userTemplate: `{"info":{"name":"{0}", "preferred_username": "{1}"}, "federation":{"cfg":{}}}`, 32 | }, 33 | } 34 | 35 | for _, tCase := range testCases { 36 | t.Run(tCase.name, func(t *testing.T) { 37 | jsonStr := sf.Format(tCase.userTemplate, tCase.userName, tCase.preferredUsername) 38 | var rawUserData interface{} 39 | err := json.Unmarshal([]byte(jsonStr), &rawUserData) 40 | assert.NoError(t, err) 41 | encoder := encoding.NewPasswordJsonEncoder("salt") 42 | user := CreateUser(rawUserData, encoder) 43 | assert.Equal(t, tCase.preferredUsername, user.GetUsername()) 44 | assert.Equal(t, tCase.isFederated, user.IsFederatedUser()) 45 | if user.IsFederatedUser() { 46 | assert.Equal(t, tCase.federationId, user.GetFederationId()) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /data/object_ext_id.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "github.com/google/uuid" 4 | 5 | // ExtendedIdentifier is a service struct that is using for association identifier and name of object like Client and User 6 | type ExtendedIdentifier struct { 7 | ID uuid.UUID 8 | Name string 9 | } 10 | -------------------------------------------------------------------------------- /data/operation_error.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | // OperationError is a struct that represents Error and contains title of error (Msg) and detailed information (Description) 4 | type OperationError struct { 5 | Msg string 6 | Description string 7 | } 8 | -------------------------------------------------------------------------------- /data/realm.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "github.com/wissance/Ferrum/utils/encoding" 4 | 5 | // Realm is a struct that describes typical Realm 6 | /* It was originally designed to efficiently work in memory with small amount of data therefore it contains relations with Clients and Users 7 | * But in a systems with thousands of users working at the same time it is too expensive to fetch Realm with all relations therefore 8 | * in such systems Clients && Users would be empty, and we should to get User or Client separately 9 | */ 10 | type Realm struct { 11 | Name string `json:"name"` 12 | Clients []Client `json:"clients"` 13 | Users []interface{} `json:"users"` 14 | TokenExpiration int `json:"token_expiration"` 15 | RefreshTokenExpiration int `json:"refresh_expiration"` 16 | UserFederationServices []UserFederationServiceConfig `json:"user_federation_services"` 17 | PasswordSalt string `json:"password_salt"` 18 | Encoder *encoding.PasswordJsonEncoder 19 | } 20 | -------------------------------------------------------------------------------- /data/server.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | // ServerData is used in managers.FileDataManager 4 | type ServerData struct { 5 | Realms []Realm 6 | } 7 | -------------------------------------------------------------------------------- /data/session.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "time" 6 | ) 7 | 8 | // UserSession is a struct that is using for store info about users logged in a Ferrum authorization server 9 | /* UserId - uuid representing unique user identifier 10 | * Started - time when token was Issued 11 | * Expired - time when session expires 12 | * RefreshExpired - time when refresh expires 13 | * JwtAccessToken and JwtRefreshToken - access and refresh tokens 14 | */ 15 | type UserSession struct { 16 | Id uuid.UUID 17 | UserId uuid.UUID 18 | Started time.Time 19 | Expired time.Time 20 | RefreshExpired time.Time 21 | JwtAccessToken string 22 | JwtRefreshToken string 23 | } 24 | -------------------------------------------------------------------------------- /data/token.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/wissance/Ferrum/utils/jsontools" 8 | ) 9 | 10 | // RawUserInfo is a type that is using for place all public user data (in Keycloak - "info":{...} struct) into JWT encoded token 11 | type RawUserInfo interface{} 12 | 13 | // JwtCommonInfo - struct with all field for representing token in JWT format 14 | type JwtCommonInfo struct { 15 | IssuedAt time.Time `json:"iat"` 16 | ExpiredAt time.Time `json:"exp"` 17 | JwtId uuid.UUID `json:"jti"` 18 | Type string `json:"typ"` 19 | Issuer string `json:"iss"` 20 | Audience string `json:"aud"` 21 | Subject uuid.UUID `json:"sub"` 22 | SessionState uuid.UUID `json:"session_state"` 23 | SessionId uuid.UUID `json:"sid"` 24 | Scope string `json:"scope"` 25 | } 26 | 27 | // TokenRefreshData is a JWT token with embedded just a common data (JwtCommonInfo) 28 | type TokenRefreshData struct { 29 | JwtCommonInfo 30 | } 31 | 32 | // AccessTokenData is a struct that stores data for build JWT access token (jwtCommonInfo, rawUserInfo) and result (ResultData, ResultJsonStr) 33 | // this token = jwtCommonInfo + rawUserInfo 34 | type AccessTokenData struct { 35 | jwtCommonInfo JwtCommonInfo 36 | rawUserInfo RawUserInfo 37 | ResultData map[string]interface{} 38 | ResultJsonStr string 39 | } 40 | 41 | // CreateAccessToken creates new AccessToken from common token data and public user info 42 | func CreateAccessToken(commonData *JwtCommonInfo, userData User) *AccessTokenData { 43 | token := AccessTokenData{jwtCommonInfo: *commonData, rawUserInfo: userData.GetUserInfo()} 44 | token.Init() 45 | return &token 46 | } 47 | 48 | // Valid is using for checking token fields values contains proper values, temporarily doesn't do anything 49 | func (token *AccessTokenData) Valid() error { 50 | // just pass formally, we don't have anything to validate, maybe in future 51 | return nil 52 | } 53 | 54 | // CreateRefreshToken creates Refresh token 55 | func CreateRefreshToken(commonData *JwtCommonInfo) *TokenRefreshData { 56 | return &TokenRefreshData{JwtCommonInfo: *commonData} 57 | } 58 | 59 | // Valid is using for checking token fields values contains proper values, temporarily doesn't do anything 60 | func (token *TokenRefreshData) Valid() error { 61 | // just pass formally, we don't have anything to validate, maybe in future 62 | return nil 63 | } 64 | 65 | // Init - combines 2 fields into map (ResultJsonStr) and simultaneously in a marshalled string ResultJsonStr 66 | func (token *AccessTokenData) Init() { 67 | data, str := jsontools.MergeNonIntersect[JwtCommonInfo, RawUserInfo](&token.jwtCommonInfo, &token.rawUserInfo) 68 | token.ResultData = data.(map[string]interface{}) 69 | token.ResultJsonStr = str 70 | } 71 | -------------------------------------------------------------------------------- /data/user.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/wissance/Ferrum/utils/encoding" 6 | ) 7 | 8 | // User is a common user interface with all Required methods to get information about user, in future we probably won't have GetPassword method 9 | // because Password is not an only method for authentication 10 | type User interface { 11 | GetUsername() string 12 | GetPasswordHash() string 13 | SetPassword(password string, encoder *encoding.PasswordJsonEncoder) error 14 | GetId() uuid.UUID 15 | GetUserInfo() interface{} 16 | GetRawData() interface{} 17 | GetJsonString() string 18 | IsFederatedUser() bool 19 | // GetFederationId actually Federation Name 20 | GetFederationId() string 21 | } 22 | 23 | var _ User = (*KeyCloakUser)(nil) 24 | -------------------------------------------------------------------------------- /data/user_federation_service_config.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | type UserFederationServiceType string 4 | 5 | const ( 6 | LDAP UserFederationServiceType = "ldap" 7 | FreeIPA UserFederationServiceType = "freeipa" 8 | ) 9 | 10 | type UserFederationServiceConfig struct { 11 | Type UserFederationServiceType `json:"type"` 12 | // Url is a base url to fetch data 13 | Url string `json:"url"` 14 | // Name is internal Unique identifier, MUST be unique across all providers 15 | Name string `json:"name"` 16 | // SysUser is a system User, if SysUser == "" then mode IsAnonymous 17 | SysUser string `json:"sys_user"` 18 | // SysPassword is a system password 19 | SysPassword string `json:"sys_password"` 20 | // TlsCfg is an HTTPS configuration options, use InsecureSkipVerify=true to allow to use self-signed certificate 21 | // TlsCfg tls.Config `json:"tls_cfg"` 22 | // EntryPoint is case of LDAP is a catalog where we should fetch data, i.e. 23 | EntryPoint string `json:"entry_point"` 24 | } 25 | 26 | func (u UserFederationServiceConfig) IsAnonymousAccess() bool { 27 | return len(u.SysUser) == 0 28 | } 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | volumes: 4 | redis_data: {} 5 | app_data: {} 6 | nginx_cfg: {} 7 | nginx_certs: {} 8 | 9 | networks: 10 | wissance_ferrum_eth: 11 | driver: bridge 12 | ipam: 13 | config: 14 | - gateway: 10.50.40.1 15 | subnet: 10.50.40.0/24 16 | 17 | 18 | services: 19 | redis: 20 | container_name: wissance_ferrum_db 21 | networks: 22 | wissance_ferrum_eth: 23 | ipv4_address: 10.50.40.2 24 | hostname: redis 25 | image: "redis/redis-stack:7.2.0-v2" 26 | env_file: 27 | - .env 28 | restart: always 29 | volumes: 30 | - redis_data:/data 31 | environment: 32 | REDIS_ARGS: ${REDIS_ARGS} 33 | ports: 34 | - "6379:6379" 35 | - "8001:8001" 36 | expose: 37 | - "6379" 38 | - "8001" 39 | 40 | ferrum: 41 | container_name: wissance_ferrum_webapi 42 | networks: 43 | wissance_ferrum_eth: 44 | ipv4_address: 10.50.40.3 45 | aliases: 46 | - ferrum.dev.local 47 | hostname: ferrum 48 | stdin_open: true 49 | tty: true 50 | env_file: 51 | - .env 52 | build: 53 | context: . 54 | dockerfile: Dockerfile 55 | volumes: 56 | - app_data:/data 57 | - nginx_cfg:/nginx_cfg 58 | environment: 59 | VIRTUAL_HOST: ferrum.dev.local 60 | WAIT_HOSTS: redis:6379 61 | depends_on: 62 | redis: 63 | condition: service_started 64 | ports: 65 | - "8182:8182" 66 | expose: 67 | - "8182" 68 | 69 | nginx-proxy: 70 | container_name: wissance_reverse_proxy 71 | image: nginxproxy/nginx-proxy 72 | networks: 73 | wissance_ferrum_eth: 74 | ipv4_address: 10.50.40.4 75 | ports: 76 | - '80:80' 77 | extra_hosts: 78 | - ferrum.dev.local:10.50.40.3 79 | depends_on: 80 | - ferrum 81 | volumes: 82 | - nginx_cfg:/etc/nginx/ 83 | - nginx_certs:/etc/ssl/certs 84 | - '/var/run/docker.sock:/tmp/docker.sock:ro' 85 | -------------------------------------------------------------------------------- /docs/2024681456_Свидетельство_ЭВМ_Ferrum_Community_Auth_Server.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wissance/Ferrum/28ae26ec6a3b53dd8f6e18840eb0776581b0731f/docs/2024681456_Свидетельство_ЭВМ_Ferrum_Community_Auth_Server.pdf -------------------------------------------------------------------------------- /docs/nginx/nginx_docker.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 4096; ## Default: 1024 3 | } 4 | http { 5 | server { 6 | listen 80; 7 | server_name ferrum.dev.local; 8 | location / { 9 | resolver 127.0.0.11; 10 | proxy_pass http://10.50.40.3:8182; 11 | # set $upstream 10.50.40.3:8182; 12 | proxy_set_header Host $host; 13 | proxy_set_header X-Real-IP $remote_addr; 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /docs/rus_software/.~lock.ferrum_usage.doc#: -------------------------------------------------------------------------------- 1 | ,MAHPELLA/umnix,MAHPELLA,20.06.2024 20:42,file:///C:/Users/umnix/AppData/Roaming/LibreOffice/4; -------------------------------------------------------------------------------- /docs/rus_software/ferrum_usage.doc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wissance/Ferrum/28ae26ec6a3b53dd8f6e18840eb0776581b0731f/docs/rus_software/ferrum_usage.doc -------------------------------------------------------------------------------- /dto/errors_details.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type ErrorDetails struct { 4 | Msg string `json:"error"` 5 | Description string `json:"error_description,omitempty"` 6 | } 7 | -------------------------------------------------------------------------------- /dto/introspect_token_result.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | // StringOrArray represents a value that can either be a string or an array of strings 4 | type StringOrArray []string 5 | 6 | type IntrospectTokenResult struct { 7 | Exp int `json:"exp,omitempty"` 8 | Nbf int `json:"nbf,omitempty"` 9 | Iat int `json:"iat,omitempty"` 10 | Aud StringOrArray `json:"aud,omitempty"` 11 | Active bool `json:"active,omitempty"` 12 | AuthTime int `json:"auth_time,omitempty"` 13 | Jti string `json:"jti,omitempty"` 14 | Type string `json:"typ,omitempty"` 15 | } 16 | -------------------------------------------------------------------------------- /dto/openid_configuration.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | // OpenIdConfiguration struct that represents OpenId Configuration like in KeyCloak 4 | // todo(UMV): this class would have only those properties (others commented) 5 | type OpenIdConfiguration struct { 6 | Issuer string `json:"issuer"` 7 | AuthorizationEndpoint string `json:"authorization_endpoint"` 8 | TokenEndpoint string `json:"token_endpoint"` 9 | IntrospectionEndpoint string `json:"introspection_endpoint"` 10 | UserInfoEndpoint string `json:"userinfo_endpoint"` 11 | EndSessionEndpoint string `json:"end_session_endpoint"` 12 | DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` 13 | RegistrationEndpoint string `json:"registration_endpoint"` 14 | PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 15 | BackChannelAuthorizationEndpoint string `json:"back_channel_authorization_endpoint"` 16 | GrantTypesSupported []string `json:"grant_types_supported"` 17 | ResponseTypesSupported []string `json:"response_types_supported"` 18 | // JwksUri string `json:"jwks_uri"` // TODO (UMV): Uncomment if required 19 | // FrontChannelLogoutSessionSupported bool // TODO (UMV): Uncomment if required 20 | // FrontChannelLogoutSupported bool // TODO (UMV): Uncomment if required 21 | // CheckSessionIframe string // TODO (UMV): Uncomment if required 22 | // SubjectTypeSupported []string // TODO (UMV): Uncomment if required 23 | //IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` 24 | //IdTokenEncryptionEncValuesSupported []string `json:"id_token_encryption_enc_values_supported"` 25 | //UserInfoSigningAlgValuesSupported []string `json:"userinfo_signing_alg_values_supported"` 26 | //RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"` 27 | //RequestEncryptionEncValuesSupported []string `json:"request_encryption_enc_values_supported"` 28 | ResponseModesSupported []string `json:"response_modes_supported"` 29 | //TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"` 30 | //IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported"` 31 | //IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported"` 32 | //AuthorizationSigningAlgValuesSupported []string `json:"authorization_signing_alg_values_supported"` 33 | //AuthorizationEncryptionAlgValuesSupported []string `json:"authorization_encryption_alg_values_supported"` 34 | //AuthorizationEncryptionEncValuesSupported []string `json:"authorization_encryption_enc_values_supported"` 35 | ClaimsSupported []string `json:"claims_supported"` 36 | ClaimTypesSupported []string `json:"claim_types_supported"` 37 | ClaimsParameterSupported bool `json:"claims_parameter_supported"` 38 | RequestParameterSupported bool `json:"request_parameter_supported"` 39 | CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` 40 | TlsClientCertificateBoundAccessToken bool `json:"tls_client_certificate_bound_access_token"` 41 | //RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported"` 42 | //RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported"` 43 | //BackChannelLogoutSupported bool // TODO (UMV): Uncomment if required 44 | //BackChannelLogoutSessionSupported bool // TODO (UMV): Uncomment if required 45 | } 46 | -------------------------------------------------------------------------------- /dto/token.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type Token struct { 4 | AccessToken string `json:"access_token"` 5 | Expires int `json:"expires_in"` 6 | RefreshExpires int `json:"refresh_expires_in"` 7 | RefreshToken string `json:"refresh_token"` 8 | TokenType string `json:"token_type"` 9 | NotBeforePolicy int `json:"not-before-policy"` 10 | Session string `json:"session_state"` 11 | Scope string `json:"scope"` 12 | } 13 | -------------------------------------------------------------------------------- /dto/token_generation_data.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type TokenGenerationData struct { 4 | ClientId string `json:"client_id" schema:"client_id"` 5 | ClientSecret string `json:"client_secret" schema:"client_secret"` 6 | GrantType string `json:"grant_type" schema:"grant_type"` 7 | Scope string `json:"scope" schema:"scope"` 8 | Username string `json:"username" schema:"username"` 9 | Password string `json:"password" schema:"password"` 10 | RefreshToken string `json:"refresh_token" schema:"refresh_token"` 11 | } 12 | -------------------------------------------------------------------------------- /errors/consts.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | const ( 4 | RealmNotProviderMsg = "You does not provided any realm" 5 | InvalidRealm = "Invalid realm" 6 | RealmDoesNotExistsTemplate = "Realm \"{0}\" does not exists" 7 | BadBodyForTokenGenerationMsg = "Bad body for token generation, see documentations" 8 | InvalidClientMsg = "Invalid client" 9 | InvalidClientCredentialDesc = "Invalid client credentials" 10 | InvalidUserCredentialsMsg = "invalid grant" 11 | InvalidUserCredentialsDesc = "Invalid user credentials" 12 | InvalidRequestMsg = "Invalid request" 13 | InvalidRequestDesc = "Token not provided" 14 | InvalidTokenMsg = "Invalid token" 15 | InvalidTokenDesc = "Token verification failed" 16 | TokenIsNotActive = "Token is not active" 17 | 18 | ServiceIsUnavailable = "Service is not available, please check again later" 19 | OtherAppError = "Other error" 20 | ) 21 | -------------------------------------------------------------------------------- /errors/data_operations_errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | sf "github.com/wissance/stringFormatter" 6 | ) 7 | 8 | var ( 9 | EmptyNotFoundErr = ObjectNotFoundError{} 10 | ErrZeroLength = errors.New("zero length") 11 | ErrNotAll = errors.New("not all values") 12 | ErrExists = ObjectAlreadyExistsError{} 13 | ErrNotExists = errors.New("not exists") 14 | ErrOperationNotSupported = errors.New("manager operation is not supported yet (temporarily or permanent)") 15 | ErrOperationNotImplemented = errors.New("manager operation is not implemented yet (wait for future releases)") 16 | ErrDataSourceNotAvailable = DataProviderNotAvailable{} 17 | ) 18 | 19 | type ObjectAlreadyExistsError struct { 20 | objectType string 21 | objectId string 22 | additionalInfo string 23 | } 24 | 25 | type ObjectNotFoundError struct { 26 | objectType string 27 | objectId string 28 | additionalInfo string 29 | } 30 | 31 | type UnknownError struct { 32 | operation string 33 | method string 34 | internalErr error 35 | } 36 | 37 | type DataProviderNotAvailable struct { 38 | providerType string 39 | source string 40 | } 41 | 42 | func NewObjectExistsError(objectType string, objectId string, additional string) ObjectAlreadyExistsError { 43 | return ObjectAlreadyExistsError{objectId: objectId, objectType: objectType, additionalInfo: additional} 44 | } 45 | 46 | func (e ObjectAlreadyExistsError) Error() string { 47 | return sf.Format("object of type \"{0}\" with id: \"{1}\" already exists in data store, additional data: {2}", e.objectType, e.objectId, 48 | e.additionalInfo) 49 | } 50 | 51 | func NewObjectNotFoundError(objectType string, objectId string, additional string) ObjectNotFoundError { 52 | return ObjectNotFoundError{objectId: objectId, objectType: objectType, additionalInfo: additional} 53 | } 54 | 55 | func (e ObjectNotFoundError) Error() string { 56 | return sf.Format("object of type \"{0}\" with id: \"{1}\" was not found in data store, additional data: {2}", e.objectType, e.objectId, 57 | e.additionalInfo) 58 | } 59 | 60 | func NewUnknownError(operation string, method string, internalErr error) UnknownError { 61 | return UnknownError{operation: operation, method: method, internalErr: internalErr} 62 | } 63 | 64 | func (e UnknownError) Error() string { 65 | return sf.Format("An error occurred during: \"{0}\" in method: \"{1}\", internal error: {2}", e.operation, e.method, e.internalErr) 66 | } 67 | 68 | func NewDataProviderNotAvailable(providerType string, source string) DataProviderNotAvailable { 69 | return DataProviderNotAvailable{providerType: providerType, source: source} 70 | } 71 | 72 | func (e DataProviderNotAvailable) Error() string { 73 | return sf.Format("{0} is not ready/up/available, please try again later", e.providerType) 74 | } 75 | -------------------------------------------------------------------------------- /errors/user_federation_service_error.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import sf "github.com/wissance/stringFormatter" 4 | 5 | type FederatedUserNotFoundError struct { 6 | FederationType string 7 | Name string 8 | Url string 9 | Username string 10 | } 11 | 12 | type MultipleUserResultError struct { 13 | Name string 14 | Username string 15 | } 16 | 17 | func (e FederatedUserNotFoundError) Error() string { 18 | return sf.Format("User: \"{0}\" was n't found in service {1} of type {2} by url: \"{3}\"", 19 | e.Username, e.Name, e.FederationType, e.Url) 20 | } 21 | 22 | func NewFederatedUserNotFound(federationType string, name string, url string, username string) FederatedUserNotFoundError { 23 | return FederatedUserNotFoundError{ 24 | FederationType: federationType, 25 | Name: name, 26 | Url: url, 27 | Username: username, 28 | } 29 | } 30 | 31 | func (e MultipleUserResultError) Error() string { 32 | return sf.Format("Multiple federated user with name: \"{0}\" for federation service: {1}", e.Username, e.Name) 33 | } 34 | 35 | func NewMultipleUserResultError(name string, username string) MultipleUserResultError { 36 | return MultipleUserResultError{ 37 | Name: name, 38 | Username: username, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /extTestApps/Wissance.Auth.FerrumChecker/Wissance.Auth.FerrumChecker.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30204.135 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wissance.Auth.FerrumChecker", "Wissance.Auth.FerrumChecker\Wissance.Auth.FerrumChecker.csproj", "{8DBDBEF0-D265-4624-81CC-CB1AC4B2444A}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {8DBDBEF0-D265-4624-81CC-CB1AC4B2444A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {8DBDBEF0-D265-4624-81CC-CB1AC4B2444A}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {8DBDBEF0-D265-4624-81CC-CB1AC4B2444A}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {8DBDBEF0-D265-4624-81CC-CB1AC4B2444A}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {852CADB0-B17E-42AC-839D-19AEE3DEF0E0} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /extTestApps/Wissance.Auth.FerrumChecker/Wissance.Auth.FerrumChecker/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.Logging; 4 | using Wissance.Authorization.Config; 5 | using Wissance.Authorization.Data; 6 | using Wissance.Authorization.OpenId; 7 | 8 | namespace Wissance.Auth.FerrumChecker 9 | { 10 | class Program 11 | { 12 | static void Main(string[] args) 13 | { 14 | KeyCloakServerConfig ferrumCfg = new KeyCloakServerConfig("http://127.0.0.1:8182", "myapp", KeyCloakClientType.Confidential, 15 | "test-service-app-client", "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz"); 16 | string defUserName = "admin"; 17 | string defPassword = "1s2d3f4g90xs"; 18 | string scope = "profile"; 19 | 20 | IOpenIdAuthenticator authenticator = new KeyCloakOpenIdAuthenticator(ferrumCfg, new LoggerFactory()); 21 | Task authenticateTask = authenticator.AuthenticateAsync(defUserName,defPassword, scope); 22 | authenticateTask.Wait(); 23 | TokenInfo token = authenticateTask.Result; 24 | if (token != null && !string.IsNullOrEmpty(token.AccessToken)) 25 | { 26 | Console.WriteLine($"Successful authentication: {token.Session}"); 27 | } 28 | else 29 | { 30 | Console.WriteLine("Authentication failed"); 31 | } 32 | 33 | string wait = Console.ReadLine(); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /extTestApps/Wissance.Auth.FerrumChecker/Wissance.Auth.FerrumChecker/Wissance.Auth.FerrumChecker.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | netcoreapp3.1 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /globals/keycloak_defs.go: -------------------------------------------------------------------------------- 1 | package globals 2 | 3 | const ( 4 | RefreshTokenGrantType = "refresh_token" 5 | AuthorizationTokenGrantType = "authorization_token" 6 | PasswordGrantType = "password" 7 | RealmPathVar = "realm" 8 | ProfileScope = "profile" 9 | ProfileEmailScope = "profile email" 10 | EmailScope = "email" 11 | OpenIdScope = "openid" 12 | TokenFormKey = "token" 13 | TokenResponseType = "token" 14 | CodeResponseType = "code" 15 | CodeTokenResponseType = "code token" 16 | SubClaimType = "sub" 17 | EmailClaimType = "email" 18 | PreferredUsernameClaim = "preferred_username" 19 | JwtResponse = "jwt" 20 | ) 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wissance/Ferrum 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/go-ldap/ldap/v3 v3.4.8 7 | github.com/golang-jwt/jwt/v4 v4.4.2 8 | github.com/google/uuid v1.6.0 9 | github.com/gorilla/handlers v1.5.1 10 | github.com/gorilla/mux v1.8.0 11 | github.com/gorilla/schema v1.2.0 12 | github.com/mattn/go-colorable v0.1.6 13 | github.com/ohler55/ojg v1.25.0 14 | github.com/redis/go-redis/v9 v9.0.2 15 | github.com/sirupsen/logrus v1.6.0 16 | github.com/stretchr/testify v1.9.0 17 | github.com/swaggo/http-swagger v1.2.8 18 | github.com/swaggo/swag v1.8.2 19 | github.com/ttys3/rotatefilehook v1.0.0 20 | github.com/wissance/go-config-extender v1.0.0 21 | github.com/wissance/gwuu v1.2.4 22 | github.com/wissance/stringFormatter v1.3.0 23 | gopkg.in/natefinch/lumberjack.v2 v2.0.0 24 | ) 25 | 26 | require ( 27 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect 28 | github.com/KyleBanks/depth v1.2.1 // indirect 29 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 30 | github.com/davecgh/go-spew v1.1.1 // indirect 31 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 32 | github.com/felixge/httpsnoop v1.0.1 // indirect 33 | github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect 34 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 35 | github.com/go-openapi/jsonreference v0.20.0 // indirect 36 | github.com/go-openapi/spec v0.20.5 // indirect 37 | github.com/go-openapi/swag v0.19.15 // indirect 38 | github.com/josharian/intern v1.0.0 // indirect 39 | github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect 40 | github.com/mailru/easyjson v0.7.6 // indirect 41 | github.com/mattn/go-isatty v0.0.12 // indirect 42 | github.com/pmezard/go-difflib v1.0.0 // indirect 43 | github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2 // indirect 44 | golang.org/x/crypto v0.27.0 // indirect 45 | golang.org/x/net v0.29.0 // indirect 46 | golang.org/x/sys v0.25.0 // indirect 47 | golang.org/x/tools v0.24.0 // indirect 48 | gopkg.in/yaml.v2 v2.4.0 // indirect 49 | gopkg.in/yaml.v3 v3.0.1 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /img/additional/cli_from_docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wissance/Ferrum/28ae26ec6a3b53dd8f6e18840eb0776581b0731f/img/additional/cli_from_docker.png -------------------------------------------------------------------------------- /img/additional/configuration_endpoint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wissance/Ferrum/28ae26ec6a3b53dd8f6e18840eb0776581b0731f/img/additional/configuration_endpoint.png -------------------------------------------------------------------------------- /img/additional/start_via_docker_compose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wissance/Ferrum/28ae26ec6a3b53dd8f6e18840eb0776581b0731f/img/additional/start_via_docker_compose.png -------------------------------------------------------------------------------- /img/ferrum_cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wissance/Ferrum/28ae26ec6a3b53dd8f6e18840eb0776581b0731f/img/ferrum_cover.png -------------------------------------------------------------------------------- /img/ferrum_cover_sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wissance/Ferrum/28ae26ec6a3b53dd8f6e18840eb0776581b0731f/img/ferrum_cover_sm.png -------------------------------------------------------------------------------- /keyfile: -------------------------------------------------------------------------------- 1 | 1e12fte2fdtefdygd2udheihkxsbhcbascbhvbhcvey3e32r32r732rhi3odhedndnasjjipdhewifpgfuwgduoewdbjbewpdehiph2eipttredgjknbvd 2 | -------------------------------------------------------------------------------- /logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mattn/go-colorable" 6 | "github.com/wissance/Ferrum/config" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "time" 12 | 13 | log "github.com/sirupsen/logrus" 14 | "github.com/ttys3/rotatefilehook" 15 | ) 16 | 17 | const ( 18 | timestampFormat = time.RFC822 19 | defaultLogLevel = log.InfoLevel 20 | ) 21 | 22 | var logLevels = map[string]log.Level{ 23 | "info": log.InfoLevel, 24 | "warn": log.WarnLevel, 25 | "error": log.ErrorLevel, 26 | "debug": log.DebugLevel, 27 | "trace": log.TraceLevel, 28 | } 29 | 30 | type AppLogger struct { 31 | logger *log.Logger 32 | loggerCfg *config.LoggingConfig 33 | } 34 | 35 | //AppLogger 36 | 37 | func CreateLogger(cfg *config.LoggingConfig) *AppLogger { 38 | return &AppLogger{loggerCfg: cfg, logger: log.New()} 39 | } 40 | 41 | func (l *AppLogger) Info(message string) { 42 | l.logger.WithFields(log.Fields{"location": l.getLocation()}).Info(message) 43 | } 44 | 45 | func (l *AppLogger) Warn(message string) { 46 | l.logger.WithFields(log.Fields{"location": l.getLocation()}).Warn(message) 47 | } 48 | 49 | func (l *AppLogger) Error(message string) { 50 | l.logger.WithFields(log.Fields{"location": l.getLocation()}).Error(message) 51 | } 52 | 53 | func (l *AppLogger) Debug(message string) { 54 | l.logger.WithFields(log.Fields{"location": l.getLocation()}).Debug(message) 55 | } 56 | 57 | func (l *AppLogger) Trace(message string) { 58 | l.logger.WithFields(log.Fields{"location": l.getLocation()}).Trace(message) 59 | } 60 | 61 | func (l *AppLogger) Init() { 62 | if l.loggerCfg == nil { 63 | return 64 | } 65 | 66 | l.logger.Out = io.Discard 67 | for _, a := range l.loggerCfg.Appenders { 68 | if !a.Enabled { 69 | continue 70 | } 71 | 72 | level := l.getLevel(l.loggerCfg.Level) 73 | level = min(level, l.getLevel(a.Level)) 74 | l.logger.SetLevel(level) 75 | 76 | switch a.Type { 77 | case config.RollingFile: 78 | logFilePath, _ := filepath.Abs(string(a.Destination.File)) 79 | logsDir := filepath.Dir(logFilePath) 80 | if _, err := os.Stat(logsDir); os.IsNotExist(err) { 81 | _ = os.Mkdir(logsDir, os.ModeAppend) 82 | } 83 | 84 | hook, _ := rotatefilehook.NewRotateFileHook(rotatefilehook.RotateFileConfig{ 85 | Filename: string(a.Destination.File), 86 | MaxSize: a.Destination.MaxSize, 87 | MaxBackups: a.Destination.MaxBackups, 88 | MaxAge: a.Destination.MaxAge, 89 | Level: level, 90 | Formatter: &log.TextFormatter{ 91 | FullTimestamp: true, 92 | TimestampFormat: timestampFormat, 93 | }, 94 | }) 95 | l.logger.AddHook(hook) 96 | 97 | case config.Console: 98 | l.logger.SetOutput(colorable.NewColorableStdout()) 99 | l.logger.SetFormatter(&log.TextFormatter{ 100 | ForceColors: true, 101 | FullTimestamp: true, 102 | TimestampFormat: timestampFormat, 103 | }) 104 | } 105 | } 106 | } 107 | 108 | func (l *AppLogger) GetAppenderIndex(appenderType config.AppenderType, appenders []config.AppenderConfig) int { 109 | for i, v := range appenders { 110 | if v.Type == appenderType { 111 | return i 112 | } 113 | } 114 | 115 | return -1 116 | } 117 | 118 | func (l *AppLogger) getLocation() string { 119 | // runtime.Caller ascends two stack frames to get to the appropriate location 120 | // and return valid line from the code 121 | _, file, line, ok := runtime.Caller(2) 122 | if !ok { 123 | file = "debug logger now its broken" 124 | line = 0 125 | return fmt.Sprintf("%s:%d :", filepath.Base(file), line) 126 | } 127 | return fmt.Sprintf("%s:%d :", filepath.Base(file), line) 128 | } 129 | 130 | func (l *AppLogger) getLevel(level string) log.Level { 131 | lev, ok := logLevels[level] 132 | if ok { 133 | return lev 134 | } 135 | return defaultLogLevel 136 | } 137 | 138 | func min(x, y log.Level) log.Level { 139 | if x < y { 140 | return x 141 | } 142 | 143 | return y 144 | } 145 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | //go:generate go install github.com/swaggo/swag/cmd/swag@v1.7.6 2 | //go:generate swag init --parseDependency --parseInternal --parseDepth 6 -o ./swagger 3 | //go:generate openssl genrsa -out ./certs/server.key 2048 4 | //go:generate openssl ecparam -genkey -name secp384r1 -out ./certs/server.key 5 | //go:generate openssl req -new -x509 -sha256 -key ./certs/server.key -out ./certs/server.crt -days 3650 -subj "/C=RU" 6 | package main 7 | 8 | import ( 9 | "context" 10 | "flag" 11 | "fmt" 12 | "os" 13 | "os/signal" 14 | "syscall" 15 | 16 | "github.com/wissance/Ferrum/application" 17 | "github.com/wissance/stringFormatter" 18 | ) 19 | 20 | const defaultConfig = "./config.json" 21 | 22 | var ( 23 | configFile = flag.String("config", defaultConfig, "--config ./config_w_redis.json") 24 | devMode = flag.Bool("devmode", false, "-devmode") 25 | ) 26 | 27 | // main is an authorization server entry point is starts and stops by signal Application 28 | /* Ferrum requires config to run via cmd line, if no config was provided defaultConfig is using 29 | * to start Ferrum with custom config (i.e. config_w_redis.json) execute following cmd ./ferrum --config ./config_w_redis.json 30 | * Ferrum stops by following signals: 31 | * 1. Interrupt = CTRL+C 32 | * 2. Terminate = signal from kill utility 33 | * 3. Hangup = also kill but with -9 arg - kill -9 34 | */ 35 | func main() { 36 | flag.Parse() 37 | osSignal := make(chan os.Signal, 1) 38 | done := make(chan bool, 1) 39 | signal.Notify(osSignal, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) 40 | 41 | ctx := context.Background() 42 | 43 | app := application.CreateAppWithConfigs(*configFile, *devMode) 44 | _, initErr := app.Init() 45 | if initErr != nil { 46 | fmt.Printf("An error occurred during app init, terminating the app: %s\n", initErr) 47 | os.Exit(-1) 48 | } 49 | logger := app.GetLogger() 50 | logger.Info("Application was successfully initialized") 51 | 52 | res, err := app.Start() 53 | if !res { 54 | msg := stringFormatter.Format("An error occurred during starting application, error is: {0}", err.Error()) 55 | fmt.Println(msg) 56 | } else { 57 | logger.Info("Application was successfully started") 58 | } 59 | 60 | // this goroutine handles OS signals and generate signal to stop the app 61 | go func() { 62 | sig := <-osSignal 63 | // logging.InfoLog(stringFormatter.Format("Got signal from OS: {0}", sig)) 64 | logger.Info(stringFormatter.Format("Got signal from OS: \"{0}\", stopping", sig)) 65 | done <- true 66 | }() 67 | // server was started in separate goroutine, main thread is waiting for signal to stop 68 | <-done 69 | 70 | res, err = app.Stop(ctx) 71 | if !res { 72 | msg := stringFormatter.Format("An error occurred during stopping application, error is: {0}", err.Error()) 73 | fmt.Println(msg) 74 | } else { 75 | logger.Info("Application was successfully stopped") 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /managers/data_context.go: -------------------------------------------------------------------------------- 1 | package managers 2 | 3 | import ( 4 | "errors" 5 | "path/filepath" 6 | 7 | "github.com/wissance/Ferrum/managers/files" 8 | "github.com/wissance/Ferrum/managers/redis" 9 | 10 | "github.com/google/uuid" 11 | "github.com/wissance/Ferrum/config" 12 | "github.com/wissance/Ferrum/data" 13 | "github.com/wissance/Ferrum/logging" 14 | "github.com/wissance/stringFormatter" 15 | ) 16 | 17 | // DataContext is a common interface to implement operations with authorization server entities (data.Realm, data.Client, data.User) 18 | // now contains only set of Get methods, during implementation admin CLI should be expanded to create && update entities 19 | // DataContext is a CRUD operations interface over a business objects (data.Realm, data.Client, data.User) 20 | type DataContext interface { 21 | // IsAvailable Checks is Data Storage accessible or not 22 | IsAvailable() bool 23 | // GetRealm returns realm by name (unique) returns realm with clients but no users 24 | GetRealm(realmName string) (*data.Realm, error) 25 | // GetClient returns realm client by name (client name is also unique in a realm) 26 | GetClient(realmName string, name string) (*data.Client, error) 27 | // GetUser return realm user (consider what to do with Federated users) by name 28 | GetUser(realmName string, userName string) (data.User, error) 29 | // GetUserFederationConfig return user federation config by name 30 | GetUserFederationConfig(realmName string, configName string) (*data.UserFederationServiceConfig, error) 31 | // GetUserById return realm user by id 32 | GetUserById(realmName string, userId uuid.UUID) (data.User, error) 33 | // CreateRealm creates new data.Realm in a data store, receive realmData unmarshalled json in a data.Realm 34 | CreateRealm(realmData data.Realm) error 35 | // CreateClient creates new data.Client in a data store, requires to pass realmName (because client name is not unique), clientData is an unmarshalled json of type data.Client 36 | CreateClient(realmName string, clientData data.Client) error 37 | // CreateUser creates new data.User in a data store within a realm with name = realmName 38 | CreateUser(realmName string, userData data.User) error 39 | // CreateUserFederationConfig creates new user federation (LDAP, FreeIPA & so on) 40 | CreateUserFederationConfig(realmName string, userFederationConfig data.UserFederationServiceConfig) error 41 | // UpdateRealm updates existing data.Realm in a data store within name = realmData, and new data = realmData 42 | UpdateRealm(realmName string, realmData data.Realm) error 43 | // UpdateClient updates existing data.Client in a data store with name = clientName and new data = clientData 44 | UpdateClient(realmName string, clientName string, clientData data.Client) error 45 | // UpdateUser updates existing data.User in a data store with realm name = realName, username = userName and data=userData 46 | UpdateUser(realmName string, userName string, userData data.User) error 47 | // UpdateUserFederationConfig updates existing user federation config 48 | UpdateUserFederationConfig(realmName string, configName string, userFederationConfig data.UserFederationServiceConfig) error 49 | // DeleteRealm removes realm from data storage (Should be a CASCADE remove of all related Users and Clients) 50 | DeleteRealm(realmName string) error 51 | // DeleteClient removes client with name = clientName from realm with name = clientName 52 | DeleteClient(realmName string, clientName string) error 53 | // DeleteUser removes data.User from data store by user (userName) and realm (realmName) name respectively 54 | DeleteUser(realmName string, userName string) error 55 | // DeleteUserFederationConfig removes data.UserFederationServiceConfig from collection 56 | DeleteUserFederationConfig(realmName string, configName string) error 57 | // SetPassword(realmName string, userName string, password string) error 58 | } 59 | 60 | // PrepareContextUsingData is a factory function that creates instance of DataContext 61 | /* This function creates instance of appropriate DataContext according to input arguments values, if dataSourceConfig is config.FILE function 62 | * creates instance of FileDataManager. 63 | * loads all data (realms, clients and users) in a memory. 64 | * Parameters: 65 | * - dataSourceCfg configuration section related to DataSource 66 | * - data - ServerData 67 | * - logger - logger instance 68 | * Return: new instance of DataContext and error (nil if there are no errors) 69 | */ 70 | func PrepareContextUsingData(dataSourceCfg *config.DataSourceConfig, data *data.ServerData, logger *logging.AppLogger) (DataContext, error) { 71 | var dc DataContext 72 | var err error 73 | switch dataSourceCfg.Type { 74 | case config.FILE: 75 | dc, err = files.CreateFileDataManagerWithInitData(data) 76 | 77 | case config.REDIS: 78 | return nil, errors.New("not supported initialization with init data") 79 | 80 | default: 81 | return nil, errors.New("not supported") 82 | } 83 | 84 | return dc, err 85 | } 86 | 87 | // PrepareContextUsingFile is a factory function that creates instance of DataContext 88 | /* This function creates instance of appropriate DataContext according to input arguments values, if dataSourceConfig is config.FILE function 89 | * creates instance of FileDataManager. For this type of context if dataFile is not nil and exists this function also provides data initialization: 90 | * loads all data (realms, clients and users) in a memory. 91 | * Parameters: 92 | * - dataSourceCfg configuration section related to DataSource 93 | * - dataFile - data for initialization (this is using only when dataSourceCfg is config.FILE) 94 | * - logger - logger instance 95 | * Return: new instance of DataContext and error (nil if there are no errors) 96 | */ 97 | func PrepareContextUsingFile(dataSourceCfg *config.DataSourceConfig, dataFile *string, logger *logging.AppLogger) (DataContext, error) { 98 | if dataFile == nil { 99 | return nil, errors.New("data file is nil") 100 | } 101 | var dc DataContext 102 | var err error 103 | switch dataSourceCfg.Type { 104 | case config.FILE: 105 | absPath, pathErr := filepath.Abs(*dataFile) 106 | if pathErr != nil { 107 | // todo: umv: think what to do on error 108 | msg := stringFormatter.Format("An error occurred during attempt to get abs path of data file: {0}", err.Error()) 109 | logger.Error(msg) 110 | err = pathErr 111 | } 112 | // init, load data in memory ... 113 | mn, mnErr := files.CreateFileDataManager(absPath, logger) 114 | if mnErr != nil { 115 | // at least and think what to do further 116 | msg := stringFormatter.Format("An error occurred during data loading: {0}", mnErr.Error()) 117 | logger.Error(msg) 118 | return nil, mnErr 119 | } 120 | dc = DataContext(mn) 121 | 122 | case config.REDIS: 123 | return nil, errors.New("not supported initialization with init data") 124 | 125 | default: 126 | return nil, errors.New("not supported") 127 | } 128 | 129 | return dc, err 130 | } 131 | 132 | // PrepareContext is a factory function that creates instance of DataContext 133 | /* If dataSourceCfg is config.REDIS this function creates instance of RedisDataManager by calling CreateRedisDataManager function 134 | * Parameters: 135 | * - dataSourceCfg configuration section related to DataSource 136 | * - logger - logger instance 137 | * Return: new instance of DataContext and error (nil if there are no errors) 138 | */ 139 | func PrepareContext(dataSourceCfg *config.DataSourceConfig, logger *logging.AppLogger) (DataContext, error) { 140 | var dc DataContext 141 | var err error 142 | switch dataSourceCfg.Type { 143 | case config.FILE: 144 | return nil, errors.New("not supported initialization without init data") 145 | 146 | case config.REDIS: 147 | dc, err = redis.CreateRedisDataManager(dataSourceCfg, logger) 148 | 149 | default: 150 | return nil, errors.New("not supported") 151 | } 152 | // todo implement other data sources 153 | 154 | return dc, err 155 | } 156 | -------------------------------------------------------------------------------- /managers/files/manager.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/wissance/Ferrum/config" 8 | "github.com/wissance/Ferrum/utils/encoding" 9 | 10 | "github.com/wissance/Ferrum/errors" 11 | 12 | "github.com/google/uuid" 13 | "github.com/wissance/Ferrum/data" 14 | "github.com/wissance/Ferrum/logging" 15 | sf "github.com/wissance/stringFormatter" 16 | ) 17 | 18 | type objectType string 19 | 20 | const ( 21 | Realm objectType = "realm" 22 | Client objectType = "client" 23 | User objectType = "user" 24 | ) 25 | 26 | // FileDataManager is the simplest Data Storage without any dependencies, it uses single JSON file (it is users and clients RO auth server) 27 | // This context type is extremely useful for simple systems 28 | type FileDataManager struct { 29 | dataFile string 30 | serverData data.ServerData 31 | logger *logging.AppLogger 32 | } 33 | 34 | // CreateFileDataManagerWithInitData initializes instance of FileDataManager and sets loaded data to serverData 35 | /* This factory function creates initialize with data instance of FileDataManager, error reserved for usage but always nil here 36 | * Parameters: 37 | * serverData already loaded data.ServerData from Json file in memory 38 | * Returns: context and error (currently is nil) 39 | */ 40 | func CreateFileDataManagerWithInitData(serverData *data.ServerData) (*FileDataManager, error) { 41 | // todo(UMV): todo provide an error handling 42 | mn := &FileDataManager{serverData: *serverData} 43 | return mn, nil 44 | } 45 | 46 | func CreateFileDataManager(dataFile string, logger *logging.AppLogger) (*FileDataManager, error) { 47 | // todo(UMV): todo provide an error handling 48 | mn := &FileDataManager{dataFile: dataFile, logger: logger} 49 | if err := mn.loadData(); err != nil { 50 | return nil, errors.NewUnknownError("data loading", "CreateFileDataManager", err) 51 | } 52 | return mn, nil 53 | } 54 | 55 | // IsAvailable methods that checks whether DataContext could be used or not 56 | /* Availability means that serverData is not empty, so simple 57 | * Parameters: no 58 | * Returns true if DataContext is available 59 | */ 60 | func (mn *FileDataManager) IsAvailable() bool { 61 | if len(mn.dataFile) > 0 { 62 | _, err := os.Stat(mn.dataFile) 63 | return err == nil 64 | } 65 | return len(mn.serverData.Realms) > 0 66 | } 67 | 68 | // GetRealm function for getting Realm by name 69 | /* Searches for a realm with name realmName in serverData adn return it. Returns realm with clients but no users 70 | * Parameters: 71 | * - realmName - name of a realm 72 | * Returns: Realm and error 73 | */ 74 | func (mn *FileDataManager) GetRealm(realmName string) (*data.Realm, error) { 75 | if !mn.IsAvailable() { 76 | return nil, errors.NewDataProviderNotAvailable(string(config.FILE), mn.dataFile) 77 | } 78 | for _, e := range mn.serverData.Realms { 79 | // case-sensitive comparison, myapp and MyApP are different realms 80 | if e.Name == realmName { 81 | e.Users = nil 82 | e.Encoder = encoding.NewPasswordJsonEncoder(e.PasswordSalt) 83 | return &e, nil 84 | } 85 | } 86 | return nil, errors.NewObjectNotFoundError(string(Realm), realmName, "") 87 | } 88 | 89 | // GetUsers function for getting all Realm User 90 | /* This function get realm by name ant extract all its users 91 | * Parameters: 92 | * - realmName - name of a realm 93 | * Returns: slice of users and error 94 | */ 95 | func (mn *FileDataManager) GetUsers(realmName string) ([]data.User, error) { 96 | if !mn.IsAvailable() { 97 | return nil, errors.NewDataProviderNotAvailable(string(config.FILE), mn.dataFile) 98 | } 99 | for _, e := range mn.serverData.Realms { 100 | // case-sensitive comparison, myapp and MyApP are different realms 101 | if e.Name == realmName { 102 | if len(e.Users) == 0 { 103 | return nil, errors.ErrZeroLength 104 | } 105 | users := make([]data.User, len(e.Users)) 106 | for i, u := range e.Users { 107 | user := data.CreateUser(u, nil) 108 | users[i] = user 109 | } 110 | return users, nil 111 | } 112 | } 113 | return nil, errors.NewObjectNotFoundError(string(User), "", sf.Format("get realm: {0} users", realmName)) 114 | } 115 | 116 | // GetClient function for getting Realm Client by name 117 | /* Searches for a client with name realmName in a realm. This function must be used after Realm was found. 118 | * Parameters: 119 | * - realmName - realm containing clients to search 120 | * - clientName - name of a client 121 | * Returns: Client and error 122 | */ 123 | func (mn *FileDataManager) GetClient(realmName string, clientName string) (*data.Client, error) { 124 | if !mn.IsAvailable() { 125 | return nil, errors.NewDataProviderNotAvailable(string(config.FILE), mn.dataFile) 126 | } 127 | realm, err := mn.GetRealm(realmName) 128 | if err != nil { 129 | mn.logger.Warn(sf.Format("GetRealm failed: {0}", err.Error())) 130 | return nil, err 131 | } 132 | 133 | for _, c := range realm.Clients { 134 | if c.Name == clientName { 135 | return &c, nil 136 | } 137 | } 138 | return nil, errors.NewObjectNotFoundError(string(Client), clientName, sf.Format("realm: {0}", realmName)) 139 | } 140 | 141 | // GetUser function for getting Realm User by userName 142 | /* Searches for a user with specified name in a realm. This function must be used after Realm was found. 143 | * Parameters: 144 | * - realmName - realm containing users to search 145 | * - userName - name of a user 146 | * Returns: User and error 147 | */ 148 | func (mn *FileDataManager) GetUser(realmName string, userName string) (data.User, error) { 149 | if !mn.IsAvailable() { 150 | return data.User(nil), errors.NewDataProviderNotAvailable(string(config.FILE), mn.dataFile) 151 | } 152 | users, err := mn.GetUsers(realmName) 153 | if err != nil { 154 | mn.logger.Warn(sf.Format("GetUsers failed: {0}", err.Error())) 155 | return nil, err 156 | } 157 | for _, u := range users { 158 | if u.GetUsername() == userName { 159 | return u, nil 160 | } 161 | } 162 | return nil, errors.NewObjectNotFoundError(string(User), userName, sf.Format("realm: {0}", realmName)) 163 | } 164 | 165 | // GetUserById function for getting Realm User by UserId (uuid) 166 | /* same functions as GetUser but uses userId to search instead of username, works by sequential iteration 167 | */ 168 | func (mn *FileDataManager) GetUserById(realmName string, userId uuid.UUID) (data.User, error) { 169 | if !mn.IsAvailable() { 170 | return data.User(nil), errors.NewDataProviderNotAvailable(string(config.FILE), mn.dataFile) 171 | } 172 | users, err := mn.GetUsers(realmName) 173 | if err != nil { 174 | mn.logger.Warn(sf.Format("GetUsers failed: {0}", err.Error())) 175 | return nil, err 176 | } 177 | for _, u := range users { 178 | if u.GetId() == userId { 179 | return u, nil 180 | } 181 | } 182 | return nil, errors.NewObjectNotFoundError(string(User), userId.String(), sf.Format("realm: {0}", realmName)) 183 | } 184 | 185 | // CreateRealm creates new data.Realm in a data store, receive realmData unmarshalled json in a data.Realm 186 | /* 187 | * 188 | */ 189 | func (mn *FileDataManager) CreateRealm(realmData data.Realm) error { 190 | return errors.ErrOperationNotImplemented 191 | } 192 | 193 | // CreateClient creates new data.Client in a data store, requires to pass realmName (because client name is not unique), clientData is an unmarshalled json of type data.Client 194 | func (mn *FileDataManager) CreateClient(realmName string, clientData data.Client) error { 195 | return errors.ErrOperationNotImplemented 196 | } 197 | 198 | // CreateUser creates new data.User in a data store within a realm with name = realmName 199 | func (mn *FileDataManager) CreateUser(realmName string, userData data.User) error { 200 | return errors.ErrOperationNotImplemented 201 | } 202 | 203 | // UpdateRealm updates existing data.Realm in a data store within name = realmData, and new data = realmData 204 | func (mn *FileDataManager) UpdateRealm(realmName string, realmData data.Realm) error { 205 | return errors.ErrOperationNotImplemented 206 | } 207 | 208 | // UpdateClient updates existing data.Client in a data store with name = clientName and new data = clientData 209 | func (mn *FileDataManager) UpdateClient(realmName string, clientName string, clientData data.Client) error { 210 | return errors.ErrOperationNotImplemented 211 | } 212 | 213 | // UpdateUser updates existing data.User in a data store with realm name = realName, username = userName and data=userData 214 | func (mn *FileDataManager) UpdateUser(realmName string, userName string, userData data.User) error { 215 | return errors.ErrOperationNotImplemented 216 | } 217 | 218 | // DeleteRealm removes realm from data storage (Should be a CASCADE remove of all related Users and Clients) 219 | func (mn *FileDataManager) DeleteRealm(realmName string) error { 220 | return errors.ErrOperationNotImplemented 221 | } 222 | 223 | // DeleteClient removes client with name = clientName from realm with name = clientName 224 | func (mn *FileDataManager) DeleteClient(realmName string, clientName string) error { 225 | return errors.ErrOperationNotImplemented 226 | } 227 | 228 | // DeleteUser removes data.User from data store by user (userName) and realm (realmName) name respectively 229 | func (mn *FileDataManager) DeleteUser(realmName string, userName string) error { 230 | return errors.ErrOperationNotImplemented 231 | } 232 | 233 | func (mn *FileDataManager) GetUserFederationConfig(realmName string, configName string) (*data.UserFederationServiceConfig, error) { 234 | return nil, errors.ErrOperationNotImplemented 235 | } 236 | 237 | func (mn *FileDataManager) CreateUserFederationConfig(realmName string, userFederationConfig data.UserFederationServiceConfig) error { 238 | return errors.ErrOperationNotImplemented 239 | } 240 | 241 | func (mn *FileDataManager) UpdateUserFederationConfig(realmName string, configName string, userFederationConfig data.UserFederationServiceConfig) error { 242 | return errors.ErrOperationNotImplemented 243 | } 244 | 245 | func (mn *FileDataManager) DeleteUserFederationConfig(realmName string, configName string) error { 246 | return errors.ErrOperationNotImplemented 247 | } 248 | 249 | // loadData this function loads data from JSON file (dataFile) to serverData 250 | func (mn *FileDataManager) loadData() error { 251 | rawData, err := os.ReadFile(mn.dataFile) 252 | if err != nil { 253 | mn.logger.Error(sf.Format("An error occurred during config file reading: {0}", err.Error())) 254 | return errors.NewUnknownError("os.ReadFile", "FileDataManager.loadData", err) 255 | } 256 | mn.serverData = data.ServerData{} 257 | if err = json.Unmarshal(rawData, &mn.serverData); err != nil { 258 | mn.logger.Error(sf.Format("An error occurred during data file unmarshal: {0}", err.Error())) 259 | return errors.NewUnknownError("json.Unmarshal", "FileDataManager.loadData", err) 260 | } 261 | 262 | return nil 263 | } 264 | -------------------------------------------------------------------------------- /managers/files/manager_test.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/google/uuid" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/wissance/Ferrum/config" 11 | "github.com/wissance/Ferrum/data" 12 | "github.com/wissance/Ferrum/logging" 13 | ) 14 | 15 | const testDataFile = "test_data.json" 16 | 17 | func TestGetRealmSuccessfully(t *testing.T) { 18 | manager := createTestFileDataManager(t) 19 | expectedRealm := data.Realm{ 20 | Name: "myapp", 21 | TokenExpiration: 330, 22 | RefreshTokenExpiration: 200, 23 | } 24 | r, err := manager.GetRealm("myapp") 25 | assert.NoError(t, err) 26 | checkRealm(t, &expectedRealm, r) 27 | } 28 | 29 | func TestGetClientSuccessfully(t *testing.T) { 30 | manager := createTestFileDataManager(t) 31 | realm := "myapp" 32 | clientId, _ := uuid.Parse("d4dc483d-7d0d-4d2e-a0a0-2d34b55e5a14") 33 | expectedClient := data.Client{ 34 | ID: clientId, 35 | Name: "test-service-app-client", 36 | Type: data.Confidential, 37 | Auth: data.Authentication{ 38 | Type: data.ClientIdAndSecrets, 39 | Value: "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz", 40 | }, 41 | } 42 | 43 | c, err := manager.GetClient(realm, expectedClient.Name) 44 | assert.NoError(t, err) 45 | checkClient(t, &expectedClient, c) 46 | } 47 | 48 | func TestGetUserSuccessfully(t *testing.T) { 49 | manager := createTestFileDataManager(t) 50 | realm := "myapp" 51 | userName := "admin" 52 | 53 | userJson := `{"info": { 54 | "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 55 | "email_verified": false, 56 | "roles": [ 57 | "admin" 58 | ], 59 | "name": "admin sys", 60 | "preferred_username": "admin", 61 | "given_name": "admin", 62 | "family_name": "sys" 63 | }, 64 | "credentials": { 65 | "password": "1s2d3f4g90xs" 66 | }}` 67 | 68 | var rawUser interface{} 69 | err := json.Unmarshal([]byte(userJson), &rawUser) 70 | assert.NoError(t, err) 71 | expectedUser := data.CreateUser(rawUser, nil) 72 | user, err := manager.GetUser(realm, userName) 73 | assert.NoError(t, err) 74 | checkUser(t, &expectedUser, &user) 75 | } 76 | 77 | func TestGetUserByIdSuccessfully(t *testing.T) { 78 | manager := createTestFileDataManager(t) 79 | realm := "myapp" 80 | userId, _ := uuid.Parse("667ff6a7-3f6b-449b-a217-6fc5d9ac0723") 81 | 82 | userJson := `{"info": { 83 | "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 84 | "email_verified": false, 85 | "roles": [ 86 | "admin" 87 | ], 88 | "name": "admin sys", 89 | "preferred_username": "admin", 90 | "given_name": "admin", 91 | "family_name": "sys" 92 | }, 93 | "credentials": { 94 | "password": "1s2d3f4g90xs" 95 | }}` 96 | 97 | var rawUser interface{} 98 | err := json.Unmarshal([]byte(userJson), &rawUser) 99 | assert.NoError(t, err) 100 | expectedUser := data.CreateUser(rawUser, nil) 101 | user, err := manager.GetUserById(realm, userId) 102 | assert.NoError(t, err) 103 | checkUser(t, &expectedUser, &user) 104 | } 105 | 106 | func createTestFileDataManager(t *testing.T) *FileDataManager { 107 | loggerCfg := config.LoggingConfig{} 108 | 109 | logger := logging.CreateLogger(&loggerCfg) 110 | 111 | manager, err := CreateFileDataManager(testDataFile, logger) 112 | require.NoError(t, err) 113 | return manager 114 | } 115 | 116 | func checkRealm(t *testing.T, expected *data.Realm, actual *data.Realm) { 117 | assert.Equal(t, expected.Name, actual.Name) 118 | assert.Equal(t, expected.TokenExpiration, actual.TokenExpiration) 119 | assert.Equal(t, expected.RefreshTokenExpiration, actual.RefreshTokenExpiration) 120 | } 121 | 122 | // nolint unused 123 | func checkClients(t *testing.T, expected *[]data.Client, actual *[]data.Client) { 124 | assert.Equal(t, len(*expected), len(*actual)) 125 | for _, e := range *expected { 126 | found := false 127 | for _, a := range *actual { 128 | if e.Name == a.Name { 129 | checkClient(t, &e, &a) 130 | found = true 131 | break 132 | } 133 | } 134 | assert.True(t, found) 135 | } 136 | } 137 | 138 | func checkClient(t *testing.T, expected *data.Client, actual *data.Client) { 139 | assert.Equal(t, expected.Name, actual.Name) 140 | assert.Equal(t, expected.Type, actual.Type) 141 | assert.Equal(t, expected.ID, actual.ID) 142 | assert.Equal(t, expected.Auth.Type, actual.Auth.Type) 143 | assert.Equal(t, expected.Auth.Value, actual.Auth.Value) 144 | } 145 | 146 | // nolint unused 147 | func checkUsers(t *testing.T, expected *[]data.User, actual *[]data.User) { 148 | assert.Equal(t, len(*expected), len(*actual)) 149 | for _, e := range *expected { 150 | // check and find actual .... 151 | found := false 152 | for _, a := range *actual { 153 | if e.GetId() == a.GetId() { 154 | checkUser(t, &e, &a) 155 | found = true 156 | break 157 | } 158 | } 159 | assert.True(t, found) 160 | } 161 | } 162 | 163 | func checkUser(t *testing.T, expected *data.User, actual *data.User) { 164 | assert.Equal(t, (*expected).GetId(), (*actual).GetId()) 165 | assert.Equal(t, (*expected).GetUsername(), (*actual).GetUsername()) 166 | assert.Equal(t, (*expected).GetPasswordHash(), (*actual).GetPasswordHash()) 167 | } 168 | -------------------------------------------------------------------------------- /managers/files/test_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "realms": [ 3 | { 4 | "name": "myapp", 5 | "token_expiration": 330, 6 | "refresh_expiration": 200, 7 | "clients": [ 8 | { 9 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5a14", 10 | "name": "test-service-app-client", 11 | "type": "confidential", 12 | "auth": { 13 | "type": 1, 14 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz" 15 | } 16 | } 17 | ], 18 | "users": [ 19 | { 20 | "info": { 21 | "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 22 | "email_verified": false, 23 | "roles": [ 24 | "admin" 25 | ], 26 | "name": "admin sys", 27 | "preferred_username": "admin", 28 | "given_name": "admin", 29 | "family_name": "sys" 30 | }, 31 | "credentials": { 32 | "password": "1s2d3f4g90xs" 33 | } 34 | }, 35 | { 36 | "info": { 37 | "sub": "8be91328-0f85-408f-966a-fd9a04ce94d9", 38 | "email_verified": false, 39 | "roles": [ 40 | "1stfloor", 41 | "manager" 42 | ], 43 | "name": "ivan ivanov", 44 | "preferred_username": "vano", 45 | "given_name": "ivan", 46 | "family_name": "ivanov" 47 | }, 48 | "credentials": { 49 | "password": "qwerty_user" 50 | } 51 | } 52 | ], 53 | "password_salt": "super_strong_salt" 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /managers/redis/manager_user_federation_service_operations.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/wissance/Ferrum/config" 8 | "github.com/wissance/Ferrum/data" 9 | appErrs "github.com/wissance/Ferrum/errors" 10 | sf "github.com/wissance/stringFormatter" 11 | ) 12 | 13 | // GetUserFederationConfig return data.UserFederationServiceConfig of configured Federation service 14 | /* This function constructs Redis key by pattern combines namespace and realm name and config name (realmUserFederationService) 15 | * all Realm Federation Config stores in Redis List Object 16 | * Parameters: 17 | * - realmName - name of a Realm 18 | * - configName - name of a User Federation Service config 19 | * Returns: config and error 20 | */ 21 | func (mn *RedisDataManager) GetUserFederationConfig(realmName string, configName string) (*data.UserFederationServiceConfig, error) { 22 | if !mn.IsAvailable() { 23 | return nil, appErrs.NewDataProviderNotAvailable(string(config.REDIS), mn.redisOption.Addr) 24 | } 25 | 26 | realmUserFederationServiceConfigKey := sf.Format(realmUserFederationServiceTemplate, mn.namespace, realmName) 27 | realmUserFederationConfig, err := getObjectsListOfNonSlicesItemsFromRedis[data.UserFederationServiceConfig](mn.redisClient, mn.ctx, mn.logger, RealmUserFederationConfig, 28 | realmUserFederationServiceConfigKey) 29 | if err != nil { 30 | if errors.Is(err, appErrs.ErrZeroLength) { 31 | return nil, appErrs.NewObjectNotFoundError(realmUserFederationServiceTemplate, configName, sf.Format("realm: {0}", realmName)) 32 | } 33 | return nil, err 34 | } 35 | for _, v := range realmUserFederationConfig { 36 | if v.Name == configName { 37 | return &v, err 38 | } 39 | } 40 | return nil, appErrs.NewObjectNotFoundError(realmUserFederationServiceTemplate, configName, sf.Format("realm: {0}", realmName)) 41 | } 42 | 43 | func (mn *RedisDataManager) GetUserFederationConfigs(realmName string) ([]data.UserFederationServiceConfig, error) { 44 | if !mn.IsAvailable() { 45 | return []data.UserFederationServiceConfig{}, appErrs.NewDataProviderNotAvailable(string(config.REDIS), mn.redisOption.Addr) 46 | } 47 | 48 | realmUserFederationServiceConfigKey := sf.Format(realmUserFederationServiceTemplate, mn.namespace, realmName) 49 | realmUserFederationConfig, err := getObjectsListOfNonSlicesItemsFromRedis[data.UserFederationServiceConfig](mn.redisClient, mn.ctx, mn.logger, 50 | RealmUserFederationConfig, realmUserFederationServiceConfigKey) 51 | return realmUserFederationConfig, err 52 | } 53 | 54 | // CreateUserFederationConfig creates new data.UserFederationServiceConfig related to data.Realm by name 55 | /* This function constructs Redis key by pattern combines namespace and realm name and config name (realmUserFederationService) 56 | * and creates config, unlike Users or Clients number of UserFederationConfig is not big, therefore we don't create a new sub-storage 57 | * Parameters: 58 | * - realmName - name of a Realm 59 | * - userFederationConfig - newly creating object data.UserFederationServiceConfig 60 | * Returns: error 61 | */ 62 | func (mn *RedisDataManager) CreateUserFederationConfig(realmName string, userFederationConfig data.UserFederationServiceConfig) error { 63 | if !mn.IsAvailable() { 64 | return appErrs.NewDataProviderNotAvailable(string(config.REDIS), mn.redisOption.Addr) 65 | } 66 | _, err := mn.getRealmObject(realmName) 67 | if err != nil { 68 | return err 69 | } 70 | // TODO(UMV): use function isExists 71 | cfg, err := mn.GetUserFederationConfig(realmName, userFederationConfig.Name) 72 | if cfg != nil { 73 | return appErrs.NewObjectExistsError(string(RealmUserFederationConfig), userFederationConfig.Name, sf.Format("realm: {0}", realmName)) 74 | } 75 | if !errors.As(err, &appErrs.ObjectNotFoundError{}) { 76 | return err 77 | } 78 | 79 | userFederationConfigBytes, err := json.Marshal(userFederationConfig) 80 | if err != nil { 81 | mn.logger.Error(sf.Format("An error occurred during Marshal UserFederationServiceConfig: {0}", err.Error())) 82 | return appErrs.NewUnknownError("json.Marshal", "RedisDataManager.CreateUserFederationConfig", err) 83 | } 84 | err = mn.createUserFederationConfigObject(realmName, string(userFederationConfigBytes)) 85 | if err != nil { 86 | return appErrs.NewUnknownError("createUserFederationConfigObject", "RedisDataManager.CreateUserFederationConfig", err) 87 | } 88 | 89 | return nil 90 | } 91 | 92 | // UpdateUserFederationConfig - updating an existing data.UserFederationServiceConfig 93 | /* Just upsert object 94 | * Arguments: 95 | * - realmName - name of a data.Realm 96 | * - configName - name of a data.UserFederationServiceConfig 97 | * - userFederationConfig - new User Federation Service Config body 98 | * Returns: error 99 | */ 100 | func (mn *RedisDataManager) UpdateUserFederationConfig(realmName string, configName string, userFederationConfig data.UserFederationServiceConfig) error { 101 | if !mn.IsAvailable() { 102 | return appErrs.NewDataProviderNotAvailable(string(config.REDIS), mn.redisOption.Addr) 103 | } 104 | _, err := mn.GetUserFederationConfig(realmName, configName) 105 | if err != nil { 106 | if errors.As(err, &appErrs.EmptyNotFoundErr) { 107 | return err 108 | } 109 | return appErrs.NewUnknownError("GetUserFederationConfig", "RedisDataManager.UpdateUserFederationConfig", err) 110 | } 111 | 112 | configBytes, err := json.Marshal(userFederationConfig) 113 | if err != nil { 114 | mn.logger.Error(sf.Format("An error occurred during Marshal UserFederationServiceConfig: {0}", err.Error())) 115 | return appErrs.NewUnknownError("json.Marshal", "RedisDataManager.UpdateUserFederationConfig", err) 116 | } 117 | 118 | err = mn.updateUserFederationConfigObject(realmName, userFederationConfig.Name, string(configBytes)) 119 | if err != nil { 120 | return appErrs.NewUnknownError("updateUserFederationConfigObject", "RedisDataManager.UpdateUserFederationConfig", err) 121 | } 122 | 123 | return nil 124 | } 125 | 126 | // DeleteUserFederationConfig removes data.UserFederationServiceConfig from storage 127 | /* It simply removes data.UserFederationServiceConfig by key based on realmName + configName 128 | * Arguments: 129 | * - realmName - name of a data.Realm 130 | * - configName - name of a data.UserFederationServiceConfig 131 | * Returns: error 132 | */ 133 | func (mn *RedisDataManager) DeleteUserFederationConfig(realmName string, configName string) error { 134 | if !mn.IsAvailable() { 135 | return appErrs.NewDataProviderNotAvailable(string(config.REDIS), mn.redisOption.Addr) 136 | } 137 | 138 | cfg, err := mn.GetUserFederationConfig(realmName, configName) 139 | if err != nil { 140 | if errors.As(err, &appErrs.EmptyNotFoundErr) { 141 | return err 142 | } 143 | return appErrs.NewUnknownError("GetUserFederationConfig", "RedisDataManager.DeleteUserFederationConfig", err) 144 | } 145 | 146 | value, _ := json.Marshal(&cfg) 147 | 148 | if err = mn.deleteUserFederationConfigObject(realmName, string(value)); err != nil { 149 | if errors.As(err, &appErrs.EmptyNotFoundErr) { 150 | return err 151 | } 152 | return appErrs.NewUnknownError("deleteUserFederationConfigObject", "RedisDataManager.DeleteUserFederationConfig", err) 153 | } 154 | 155 | return nil 156 | } 157 | 158 | // createUserFederationConfigObject - create (append )data.UserFederationServiceConfig to appropriate LIST object related to a Realm 159 | /* We don't check whether we have such item in LIST or not here because we do it in CreateUserFederationConfig function 160 | * Arguments: 161 | * - realmName name of a data.Realm 162 | * - userFederationJson - string with serialized (Marshalled object) 163 | * Returns: error 164 | */ 165 | func (mn *RedisDataManager) createUserFederationConfigObject(realmName string, userFederationJson string) error { 166 | realmConfigsKey := sf.Format(realmUserFederationServiceTemplate, mn.namespace, realmName) 167 | _, err := getObjectsListOfNonSlicesItemsFromRedis[data.UserFederationServiceConfig](mn.redisClient, mn.ctx, mn.logger, RealmUserFederationConfig, realmConfigsKey) 168 | if err != nil { 169 | if errors.Is(err, appErrs.ErrZeroLength) { 170 | } else { 171 | return appErrs.NewUnknownError("getObjectsListOfNonSlicesItemsFromRedis", "RedisDataManager.createUserFederationConfigObject", err) 172 | } 173 | } 174 | 175 | if err = mn.appendStringToRedisList(RealmUserFederationConfig, realmConfigsKey, userFederationJson); err != nil { 176 | return appErrs.NewUnknownError("upsertRedisString", "RedisDataManager.createUserFederationConfigObject", err) 177 | } 178 | return nil 179 | } 180 | 181 | // createUserFederationConfigObject - updates a data.UserFederationServiceConfig 182 | /* We are iterating here through whole list of data.UserFederationServiceConfig related to Realm with realmName, if there are no such item, 183 | * an error of type appErrs.ObjectNotFoundError will be rise up 184 | * Arguments: 185 | * - realmName name of a data.Realm 186 | * - userFederation - user federation 187 | * Returns: error 188 | */ 189 | func (mn *RedisDataManager) updateUserFederationConfigObject(realmName string, userFederationName string, userFederationJson string /**data.UserFederationServiceConfig*/) error { 190 | realmConfigsKey := sf.Format(realmUserFederationServiceTemplate, mn.namespace, realmName) 191 | configs, err := getObjectsListOfNonSlicesItemsFromRedis[data.UserFederationServiceConfig](mn.redisClient, mn.ctx, mn.logger, RealmUserFederationConfig, realmConfigsKey) 192 | if err != nil { 193 | if errors.Is(err, appErrs.ErrZeroLength) { 194 | } else { 195 | return appErrs.NewUnknownError("getObjectsListOfNonSlicesItemsFromRedis", "RedisDataManager.updateUserFederationConfigObject", err) 196 | } 197 | } 198 | 199 | for k, v := range configs { 200 | if v.Name == userFederationName { 201 | return updateObjectListItemInRedis[string](mn.redisClient, mn.ctx, mn.logger, RealmUserFederationConfig, 202 | realmConfigsKey, int64(k), userFederationJson) 203 | } 204 | } 205 | 206 | return appErrs.NewObjectNotFoundError(string(RealmUserFederationConfig), userFederationName, sf.Format("Realm: {0}", realmName)) 207 | } 208 | 209 | // deleteUserFederationConfigObject - deleting a data.UserFederationServiceConfig 210 | /* Inside uses realmUserFederationService 211 | * Arguments: 212 | * - realmName - name of data.Realm 213 | * - configName - name of data.UserFederationServiceConfig 214 | * Returns: error 215 | */ 216 | func (mn *RedisDataManager) deleteUserFederationConfigObject(realmName string, value string) error { 217 | configKey := sf.Format(realmUserFederationServiceTemplate, mn.namespace, realmName) 218 | if err := mn.deleteRedisListItem(RealmUserFederationConfig, configKey, value); err != nil { 219 | if errors.As(err, &appErrs.EmptyNotFoundErr) { 220 | return err 221 | } 222 | return appErrs.NewUnknownError("deleteUserFederationConfigObject", "RedisDataManager.deleteUserFederationConfigObject", err) 223 | } 224 | return nil 225 | } 226 | -------------------------------------------------------------------------------- /services/federation/ldap_federation_service.go: -------------------------------------------------------------------------------- 1 | package federation 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/go-ldap/ldap/v3" 7 | "github.com/wissance/Ferrum/data" 8 | appErrs "github.com/wissance/Ferrum/errors" 9 | "github.com/wissance/Ferrum/logging" 10 | sf "github.com/wissance/stringFormatter" 11 | ) 12 | 13 | const userNameLdapFilterTemplate = "(SAMAccountName={0})" 14 | 15 | // LdapUserFederation is a service that is responsible for User Federation using Ldap protocol 16 | // todo(UMV): this is a preliminary implementation (not tested yet) 17 | type LdapUserFederation struct { 18 | logger *logging.AppLogger 19 | config *data.UserFederationServiceConfig 20 | } 21 | 22 | func CreateLdapUserFederationService(config *data.UserFederationServiceConfig, logger *logging.AppLogger) (*LdapUserFederation, error) { 23 | return &LdapUserFederation{config: config, logger: logger}, nil 24 | } 25 | 26 | // GetUser builds user from data from federation service 27 | /* 28 | * Useful resources: 29 | * 1. https://dev.to/openlab/ldap-authentication-in-golang-with-bind-and-search-47h5 30 | */ 31 | func (s *LdapUserFederation) GetUser(userName string, mask string) (data.User, error) { 32 | // todo(UMV): add TLS config 33 | conn, err := ldap.DialURL(s.config.Url) 34 | defer func() { 35 | _ = conn.Close() 36 | }() 37 | if err != nil { 38 | s.logger.Error(sf.Format("An error occurred during LDAP URL dial: {0}", err.Error())) 39 | return nil, err 40 | } 41 | if s.config.IsAnonymousAccess() { 42 | err = conn.UnauthenticatedBind("") 43 | if err != nil { 44 | s.logger.Error(sf.Format("An error occurred during LDAP Unauthenticated bind: {0}", err.Error())) 45 | return nil, err 46 | } 47 | } else { 48 | err = conn.Bind(s.config.SysUser, s.config.SysPassword) 49 | if err != nil { 50 | s.logger.Error(sf.Format("An error occurred during LDAP Bind: {0}", err.Error())) 51 | return nil, err 52 | } 53 | } 54 | // Search for a user ... 55 | userFilter := sf.Format(userNameLdapFilterTemplate, userName) 56 | searchReq := ldap.NewSearchRequest(s.config.EntryPoint, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 57 | 0, 0, false, userFilter, []string{}, nil) 58 | 59 | result, err := conn.Search(searchReq) 60 | if err != nil { 61 | s.logger.Error(sf.Format("An error occurred during LDAP Request Search: {0}", err.Error())) 62 | return nil, err 63 | } 64 | 65 | if result != nil { 66 | if len(result.Entries) == 0 { 67 | return nil, appErrs.NewFederatedUserNotFound(string(s.config.Type), s.config.Name, s.config.Url, userName) 68 | } 69 | 70 | if len(result.Entries) > 1 { 71 | return nil, appErrs.NewMultipleUserResultError(s.config.Name, userName) 72 | } 73 | } 74 | 75 | // todo(UMV): convert []Attributes to Json and pass 76 | // result.Entries[0].Attributes[0].Name 77 | 78 | return nil, nil 79 | } 80 | 81 | func (s *LdapUserFederation) GetUsers(mask string) []data.User { 82 | return []data.User{} 83 | } 84 | 85 | func (s *LdapUserFederation) Authenticate(userName string, password string) (bool, error) { 86 | return false, errors.New("not implemented yet") 87 | } 88 | 89 | func (s *LdapUserFederation) Init() { 90 | } 91 | -------------------------------------------------------------------------------- /services/federation/user_federation_service.go: -------------------------------------------------------------------------------- 1 | package federation 2 | 3 | import ( 4 | "github.com/wissance/Ferrum/data" 5 | "github.com/wissance/Ferrum/logging" 6 | sf "github.com/wissance/stringFormatter" 7 | ) 8 | 9 | // UserFederation is interface to external User Storage systems (AD, LDAP or FreeIPA) 10 | /* UserFederation instances are classes that have config to connect external providers 11 | * and Authenticate in system using this provider 12 | */ 13 | type UserFederation interface { 14 | // GetUser searches for User in external Provider and return data.User mapped with mask (jsonpath) 15 | GetUser(userName string, mask string) (data.User, error) 16 | // GetUsers searches for Users in external Provider and return []data.User mapped with mask (jsonpath) 17 | GetUsers(mask string) []data.User 18 | // Authenticate method for Authenticate in external Provider 19 | Authenticate(userName string, password string) (bool, error) 20 | } 21 | 22 | // CreateUserFederationService is a factory method that creates 23 | func CreateUserFederationService(config *data.UserFederationServiceConfig, logger *logging.AppLogger) (UserFederation, error) { 24 | if config.Type == data.LDAP { 25 | s, err := CreateLdapUserFederationService(config, logger) 26 | if err != nil { 27 | logger.Error(sf.Format("An error occurred during Ldap User Federation service creation: {0}", err.Error())) 28 | return nil, err 29 | } 30 | return s, nil 31 | } 32 | return nil, nil 33 | } 34 | -------------------------------------------------------------------------------- /services/jwt_generator_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "strings" 7 | 8 | "github.com/golang-jwt/jwt/v4" 9 | "github.com/google/uuid" 10 | "github.com/wissance/Ferrum/data" 11 | "github.com/wissance/Ferrum/logging" 12 | "github.com/wissance/stringFormatter" 13 | ) 14 | 15 | // JwtGenerator is useful struct that has methods to generate JWT tokens using golang-jwt utility 16 | type JwtGenerator struct { 17 | // TODO(UMV): we should add possibility to regenerate SignKey (probably via CLI) 18 | SignKey []byte 19 | Logger *logging.AppLogger 20 | } 21 | 22 | // GenerateJwtAccessToken generates encoded string of access token in JWT format 23 | /* This function combines a lot of arguments into one big JSON and encode it using SignKey (should be loaded by application) 24 | * Parameters: 25 | * - realmBaseUrl - common path of all routes, usually ~/auth/realms/{realm}/ (see api/rest/getRealmBaseUrl) 26 | * - tokenType - string with type of token, rest.Bearer 27 | * - scope - verification scope, currently used only globals.ProfileEmailScope 28 | * - sessionData - full session data of authorized user 29 | * - userData - full public user data 30 | * Returns: JWT-encoded string with access token 31 | */ 32 | func (generator *JwtGenerator) GenerateJwtAccessToken(realmBaseUrl string, tokenType string, scope string, sessionData *data.UserSession, 33 | userData data.User) string { 34 | accessToken := generator.prepareAccessToken(realmBaseUrl, tokenType, scope, sessionData, userData) 35 | return generator.generateJwtAccessToken(accessToken) 36 | } 37 | 38 | // GenerateJwtRefreshToken generates encoded string of refresh token in JWT format 39 | /* This function combines a lot of arguments into one big JSON and encode it using SignKey (should be loaded by application). 40 | * FULLY SIMILAR To GenerateJwtAccessToken except it has not userData like previous func 41 | * Parameters: 42 | * - realmBaseUrl - common path of all routes, usually ~/auth/realms/{realm}/ (see api/rest/getRealmBaseUrl) 43 | * - tokenType - string with type of token, rest.Refresh 44 | * - scope - verification scope, currently used only globals.ProfileEmailScope 45 | * - sessionData - full session data of authorized user 46 | * Returns: JWT-encoded string with refresh token 47 | */ 48 | func (generator *JwtGenerator) GenerateJwtRefreshToken(realmBaseUrl string, tokenType string, scope string, sessionData *data.UserSession) string { 49 | refreshToken := generator.prepareRefreshToken(realmBaseUrl, tokenType, scope, sessionData) 50 | return generator.generateJwtRefreshToken(refreshToken) 51 | } 52 | 53 | // generateJwtAccessToken this is actual access token JWT generation with SignKey as a Token signature (HMAC-SHA-256) 54 | func (generator *JwtGenerator) generateJwtAccessToken(tokenData *data.AccessTokenData) string { 55 | token := jwt.New(jwt.SigningMethodHS256) 56 | // signed token contains embedded type because we don't actually know type of User, therefore we do it like jwt do but use RawStr 57 | signedToken, err := generator.makeSignedToken(token, tokenData, generator.SignKey) 58 | //token.SignedString([]byte("secureSecretText")) 59 | if err != nil { 60 | //todo(UMV): think what to do on Error 61 | generator.Logger.Error(stringFormatter.Format("An error occurred during signed Jwt Access Token Generation: {0}", err.Error())) 62 | } 63 | 64 | return signedToken 65 | } 66 | 67 | // generateJwtAccessToken this is actual refresh token JWT generation with SignKey as a Token signature (HMAC-SHA-256) 68 | func (generator *JwtGenerator) generateJwtRefreshToken(tokenData *data.TokenRefreshData) string { 69 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, tokenData) 70 | signedToken, err := token.SignedString(generator.SignKey) 71 | if err != nil { 72 | //todo(UMV): think what to do on Error 73 | generator.Logger.Error(stringFormatter.Format("An error occurred during signed Jwt Refresh Token Generation: {0}", err.Error())) 74 | } 75 | return signedToken 76 | } 77 | 78 | // prepareAccessToken builds data.AccessTokenData from a lot of params 79 | func (generator *JwtGenerator) prepareAccessToken(realmBaseUrl string, tokenType string, scope string, sessionData *data.UserSession, 80 | userData data.User) *data.AccessTokenData { 81 | issuer := realmBaseUrl 82 | jwtCommon := data.JwtCommonInfo{Issuer: issuer, Type: tokenType, Audience: "account", Scope: scope, JwtId: uuid.New(), 83 | IssuedAt: sessionData.Started, ExpiredAt: sessionData.Expired, Subject: sessionData.UserId, 84 | SessionId: sessionData.Id, SessionState: sessionData.Id} 85 | accessToken := data.CreateAccessToken(&jwtCommon, userData) 86 | return accessToken 87 | } 88 | 89 | // prepareRefreshToken builds data.TokenRefreshData from a lot of params 90 | func (generator *JwtGenerator) prepareRefreshToken(realmBaseUrl string, tokenType string, scope string, sessionData *data.UserSession) *data.TokenRefreshData { 91 | issuer := realmBaseUrl 92 | jwtCommon := data.JwtCommonInfo{Issuer: issuer, Type: tokenType, Audience: issuer, Scope: scope, JwtId: uuid.New(), 93 | IssuedAt: sessionData.Started, ExpiredAt: sessionData.Expired, Subject: sessionData.UserId, 94 | SessionId: sessionData.Id, SessionState: sessionData.Id} 95 | accessToken := data.CreateRefreshToken(&jwtCommon) 96 | return accessToken 97 | } 98 | 99 | // makeSignedToken this function adds signature to token 100 | func (generator *JwtGenerator) makeSignedToken(token *jwt.Token, tokenData *data.AccessTokenData, signKey interface{}) (string, error) { 101 | var err error 102 | var sig string 103 | var jsonValue []byte 104 | 105 | if jsonValue, err = json.Marshal(token.Header); err != nil { 106 | return "", err 107 | } 108 | header := base64.RawURLEncoding.EncodeToString(jsonValue) 109 | 110 | claim := base64.RawURLEncoding.EncodeToString([]byte(tokenData.ResultJsonStr)) 111 | 112 | unsignedToken := strings.Join([]string{header, claim}, ".") 113 | if sig, err = token.Method.Sign(unsignedToken, signKey); err != nil { 114 | return "", err 115 | } 116 | return strings.Join([]string{unsignedToken, sig}, "."), nil 117 | } 118 | -------------------------------------------------------------------------------- /services/security.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/wissance/Ferrum/data" 6 | "github.com/wissance/Ferrum/dto" 7 | ) 8 | 9 | // SecurityService is an interface that implements all checks and manipulation with sessions data 10 | type SecurityService interface { 11 | // Validate checks whether provided tokenIssueData could be used for token generation or not 12 | Validate(tokenIssueData *dto.TokenGenerationData, realm *data.Realm) *data.OperationError 13 | // CheckCredentials validates provided in tokenIssueData pairs of clientId+clientSecret and username+password 14 | CheckCredentials(tokenIssueData *dto.TokenGenerationData, realmName string) *data.OperationError 15 | // GetCurrentUserByName return CurrentUser data by name 16 | GetCurrentUserByName(realmName string, userName string) data.User 17 | // GetCurrentUserById return CurrentUser data by id 18 | GetCurrentUserById(realmName string, userId uuid.UUID) data.User 19 | // StartOrUpdateSession starting new session on new successful token issue request or updates existing one with new request with valid token 20 | StartOrUpdateSession(realm string, userId uuid.UUID, duration int, refresh int) uuid.UUID 21 | // AssignTokens this function creates relation between userId and issued tokens (access and refresh) 22 | AssignTokens(realm string, userId uuid.UUID, accessToken *string, refreshToken *string) 23 | // GetSession returns user session data 24 | GetSession(realm string, userId uuid.UUID) *data.UserSession 25 | // GetSessionByAccessToken returns session data by access token 26 | GetSessionByAccessToken(realm string, token *string) *data.UserSession 27 | // GetSessionByRefreshToken returns session data by access token 28 | GetSessionByRefreshToken(realm string, token *string) *data.UserSession 29 | // CheckSessionAndRefreshExpired checks is user tokens expired or not (could user use them or should get new ones) 30 | CheckSessionAndRefreshExpired(realm string, userId uuid.UUID) (bool, bool) 31 | } 32 | -------------------------------------------------------------------------------- /services/token_based_security.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "time" 5 | 6 | sf "github.com/wissance/stringFormatter" 7 | 8 | "github.com/google/uuid" 9 | "github.com/wissance/Ferrum/data" 10 | "github.com/wissance/Ferrum/dto" 11 | "github.com/wissance/Ferrum/errors" 12 | "github.com/wissance/Ferrum/logging" 13 | "github.com/wissance/Ferrum/managers" 14 | ) 15 | 16 | // TokenBasedSecurityService structure that implements SecurityService 17 | type TokenBasedSecurityService struct { 18 | DataProvider *managers.DataContext 19 | UserSessions map[string][]data.UserSession 20 | logger *logging.AppLogger 21 | } 22 | 23 | // CreateSecurityService creates instance of TokenBasedSecurityService as SecurityService 24 | /* This function creates SecurityService based on dataProvider as managers.DataContext 25 | * Parameters: 26 | * - dataProvider - any managers.DataContext implementation (config.FILE, config.REDIS) 27 | * - logger - logger service 28 | * Returns instance of TokenBasedSecurityService as SecurityService 29 | */ 30 | func CreateSecurityService(dataProvider *managers.DataContext, logger *logging.AppLogger) SecurityService { 31 | pwdSecService := &TokenBasedSecurityService{DataProvider: dataProvider, UserSessions: map[string][]data.UserSession{}, logger: logger} 32 | secService := SecurityService(pwdSecService) 33 | return secService 34 | } 35 | 36 | // Validate functions that check whether provided clientId and clientSecret valid or not 37 | /* First this function get find data.Realm data.Client by clientId, if client is data.Public there is nothing to do, for confidential 38 | * clients function checks provided clientSecret 39 | * Parameters: 40 | * - tokenIssueData data required for issue new token 41 | * - realm - obtained from managers.DataContext realm 42 | * Returns: nil if Validation passed, otherwise error (data.OperationError) with description 43 | */ 44 | func (service *TokenBasedSecurityService) Validate(tokenIssueData *dto.TokenGenerationData, realm *data.Realm) *data.OperationError { 45 | for _, c := range realm.Clients { 46 | if c.Name == tokenIssueData.ClientId { 47 | if c.Type == data.Public { 48 | service.logger.Trace("Public client was successfully validated") 49 | return nil 50 | } 51 | 52 | // here we make deal with confidential client 53 | if c.Auth.Type == data.ClientIdAndSecrets && c.Auth.Value == tokenIssueData.ClientSecret { 54 | service.logger.Trace("Private client was successfully validated") 55 | return nil 56 | } 57 | 58 | } 59 | } 60 | return &data.OperationError{Msg: errors.InvalidClientMsg, Description: errors.InvalidClientCredentialDesc} 61 | } 62 | 63 | // CheckCredentials function that checks provided credentials (username and password) 64 | /* This function extracts data.User from DataProvider and also this function checks password from user credentials 65 | * Parameters: 66 | * - tokenIssueData - issues token 67 | * - realm - name of a data.Realm 68 | * Returns: nil if credentials are valid, otherwise error (data.OperationError) with description 69 | */ 70 | func (service *TokenBasedSecurityService) CheckCredentials(tokenIssueData *dto.TokenGenerationData, realmName string) *data.OperationError { 71 | user, _ := (*service.DataProvider).GetUser(realmName, tokenIssueData.Username) 72 | if user == nil { 73 | service.logger.Trace("Credential check: username mismatch") 74 | return &data.OperationError{Msg: errors.InvalidUserCredentialsMsg, Description: errors.InvalidUserCredentialsDesc} 75 | } 76 | 77 | realm, err := (*service.DataProvider).GetRealm(realmName) 78 | if err != nil { 79 | service.logger.Trace("Credential check: failed to get realm") 80 | return &data.OperationError{Msg: "failed to get realm", Description: err.Error()} 81 | } 82 | 83 | if user.IsFederatedUser() { 84 | msg := sf.Format("User \"{0}\" configured as federated, currently it is not fully supported, wait for future releases", 85 | user.GetUsername()) 86 | service.logger.Warn(msg) 87 | return &data.OperationError{Msg: "federated user not supported", Description: msg} 88 | } else { 89 | oldPasswordHash := user.GetPasswordHash() 90 | if !realm.Encoder.IsPasswordsMatch(tokenIssueData.Password, oldPasswordHash) { 91 | service.logger.Trace("Credential check: password mismatch") 92 | return &data.OperationError{Msg: errors.InvalidUserCredentialsMsg, Description: errors.InvalidUserCredentialsDesc} 93 | } 94 | return nil 95 | } 96 | } 97 | 98 | // GetCurrentUserByName return public user info by username 99 | /* This function simply return user by name, by querying user from DataProvider 100 | * Parameters: 101 | * - realm - realm previously obtained from DataProvider 102 | * - userName - name of user 103 | * Returns user from DataProvider or nil (user not found) 104 | */ 105 | func (service *TokenBasedSecurityService) GetCurrentUserByName(realmName string, userName string) data.User { 106 | user, _ := (*service.DataProvider).GetUser(realmName, userName) 107 | return user 108 | } 109 | 110 | // GetCurrentUserById return public user info by username 111 | /* This function simply return user by id, by querying user from DataProvider 112 | * Parameters: 113 | * - realm - realm previously obtained from DataProvider 114 | * - userId - user identifier 115 | * Returns user from DataProvider or nil (user not found) 116 | */ 117 | func (service *TokenBasedSecurityService) GetCurrentUserById(realmName string, userId uuid.UUID) data.User { 118 | user, _ := (*service.DataProvider).GetUserById(realmName, userId) 119 | return user 120 | } 121 | 122 | // StartOrUpdateSession this function starts new session or updates existing one 123 | /* This function starts new session when user successfully gets access token, duration && refresh takes from data.Realm data.Client 124 | * Sessions storing in internal memory, probably it will be changed and store as temporary key 125 | * Parameters: 126 | * - realm - realm name 127 | * - userId - user identifier 128 | * - duration - access token == session duration 129 | * - refresh - refresh token duration 130 | * Returns: identifier of session 131 | */ 132 | func (service *TokenBasedSecurityService) StartOrUpdateSession(realm string, userId uuid.UUID, duration int, refresh int) uuid.UUID { 133 | realmSessions, ok := service.UserSessions[realm] 134 | sessionId := uuid.New() 135 | // if there are no realm sessions ... 136 | if !ok { 137 | started := time.Now() 138 | userSession := data.UserSession{ 139 | Id: sessionId, UserId: userId, Started: started, 140 | Expired: started.Add(time.Second * time.Duration(duration)), 141 | RefreshExpired: started.Add(time.Second * time.Duration(refresh)), 142 | } 143 | service.UserSessions[realm] = append(realmSessions, userSession) 144 | return sessionId 145 | } 146 | // realm session exists, we should find and update Expired values OR add new 147 | for i, s := range realmSessions { 148 | if s.UserId == userId { 149 | realmSessions[i].Expired = time.Now().Add(time.Second * time.Duration(duration)) 150 | realmSessions[i].RefreshExpired = time.Now().Add(time.Second * time.Duration(refresh)) 151 | service.UserSessions[realm] = realmSessions 152 | return s.Id 153 | } 154 | } 155 | // such session does not exist, adding 156 | userSession := data.UserSession{ 157 | Id: sessionId, UserId: userId, Started: time.Now(), 158 | Expired: time.Now().Add(time.Second * time.Duration(duration)), 159 | } 160 | service.UserSessions[realm] = append(realmSessions, userSession) 161 | return userSession.Id 162 | } 163 | 164 | // AssignTokens saves obtained tokens in existing UserSession 165 | /* This function saves tokens in existing session searching it by userId (session must exist) 166 | * Parameters: 167 | * - realm - name of realm 168 | * - userId - user identifier 169 | * - accessToken - obtained access token 170 | * - refreshToken - obtained refresh token 171 | * Returns nothing 172 | */ 173 | func (service *TokenBasedSecurityService) AssignTokens(realm string, userId uuid.UUID, accessToken *string, refreshToken *string) { 174 | realmSessions, ok := service.UserSessions[realm] 175 | if ok { 176 | // index := -1 177 | for i, s := range realmSessions { 178 | if s.UserId == userId { 179 | realmSessions[i].JwtAccessToken = *accessToken 180 | realmSessions[i].JwtRefreshToken = *refreshToken 181 | service.UserSessions[realm] = realmSessions 182 | break 183 | } 184 | } 185 | } 186 | } 187 | 188 | // GetSession returns user session related to user 189 | /* Function iterates over sessions and searches appropriate session by comparing userId with s.UserId 190 | * Parameters: 191 | * - realm - name of a realm 192 | * - userId - user identifier 193 | * Returns data.UserSession if found or nil 194 | */ 195 | func (service *TokenBasedSecurityService) GetSession(realm string, userId uuid.UUID) *data.UserSession { 196 | realmSessions, ok := service.UserSessions[realm] 197 | if !ok { 198 | return nil 199 | } 200 | for _, s := range realmSessions { 201 | if s.UserId == userId { 202 | return &s 203 | } 204 | } 205 | return nil 206 | } 207 | 208 | // GetSessionByAccessToken returns user session related to user by access token 209 | /* Function iterates over sessions and searches appropriate session by comparing token with s.JwtAccessToken 210 | * Parameters: 211 | * - realm - name of a realm 212 | * - token - access token 213 | * Returns data.UserSession if found or nil 214 | */ 215 | func (service *TokenBasedSecurityService) GetSessionByAccessToken(realm string, token *string) *data.UserSession { 216 | realmSessions, ok := service.UserSessions[realm] 217 | if !ok { 218 | return nil 219 | } 220 | for _, s := range realmSessions { 221 | if s.JwtAccessToken == *token { 222 | return &s 223 | } 224 | } 225 | return nil 226 | } 227 | 228 | // GetSessionByRefreshToken returns user session related to user by refresh token 229 | /* Function iterates over sessions and searches appropriate session by comparing token with s.JwtRefreshToken 230 | * Parameters: 231 | * - realm - name of a realm 232 | * - token - refresh token 233 | * Returns data.UserSession if found or nil 234 | */ 235 | func (service *TokenBasedSecurityService) GetSessionByRefreshToken(realm string, token *string) *data.UserSession { 236 | realmSessions, ok := service.UserSessions[realm] 237 | if !ok { 238 | return nil 239 | } 240 | for _, s := range realmSessions { 241 | if s.JwtRefreshToken == *token { 242 | return &s 243 | } 244 | } 245 | return nil 246 | } 247 | 248 | // CheckSessionAndRefreshExpired this function checks both token are expired or not 249 | /* This function compares current time with expiration time (usually refresh token expires earlier than access) 250 | * Parameters: 251 | * - realm - name of a realm 252 | * - userId - user identifier 253 | * Returns tuple of (bool, bool) with values for access token (first) and refresh token (second) expired. If token expired value is true. 254 | */ 255 | func (service *TokenBasedSecurityService) CheckSessionAndRefreshExpired(realm string, userId uuid.UUID) (bool, bool) { 256 | s := service.GetSession(realm, userId) 257 | if s == nil { 258 | return true, true 259 | } 260 | current := time.Now().In(time.UTC) 261 | return s.Expired.In(time.UTC).Before(current), s.RefreshExpired.In(time.UTC).Before(current) 262 | } 263 | -------------------------------------------------------------------------------- /testData/redis/data.md: -------------------------------------------------------------------------------- 1 | This is text contains set of small JSONs (sample data) 4 testing the app. 2 | We are using `ferrum_1` as a Redis namespace (prefix before every key) 3 | 4 | 1. Realms 5 | ```json 6 | { 7 | "ferrum_1.realm_myApp": { 8 | "name": "myApp", 9 | "token_expiration": 600, 10 | "refresh_expiration": 300, 11 | "clients": [ 12 | ] 13 | }, 14 | "ferrum_1.realm_testApp": { 15 | "name": "testapp", 16 | "token_expiration": 6000, 17 | "refresh_expiration": 3000, 18 | "clients": [ 19 | ] 20 | } 21 | } 22 | ``` 23 | `myApp` and `testApp` are ACTUAL Realms names, but they must be stored in `Redis` with keys `ferrum_1.realm_myApp` and `ferrum_1.testApp` respectively, we left client 24 | blank, clients to realm relation is setting in a separate object 25 | 26 | 2. Clients 27 | All the clients should have the following key pattern `namespace.{realmName}_client_{clientName}` 28 | ```json 29 | { 30 | "ferrum_1.myApp_client_test-service-app-client": { 31 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5a14", 32 | "name": "test-service-app-client", 33 | "type": "confidential", 34 | "auth": { 35 | "type": 1, 36 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz" 37 | } 38 | }, 39 | "ferrum_1.myApp_client_test-mobile-app-client": { 40 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5199", 41 | "name": "test-mobile-app-client", 42 | "type": "confidential", 43 | "auth": { 44 | "type": 1, 45 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz" 46 | } 47 | }, 48 | "ferrum_1.testApp_client_test-test-app-client": { 49 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5207", 50 | "name": "test-test-app-client", 51 | "type": "confidential", 52 | "auth": { 53 | "type": 1, 54 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz" 55 | } 56 | } 57 | } 58 | ``` 59 | 3. Clients to Realm binding i.e. consider realm `myApp` we should add all clients identifiers+names as array to object with key `ferrum_1.realm_myApp_clients` 60 | ```json 61 | { 62 | "ferrum_1.realm_myApp_clients": [ 63 | { 64 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5a14", 65 | "name": "test-service-app-client" 66 | }, 67 | { 68 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5199", 69 | "name": "test-mobile-app-client" 70 | } 71 | ], 72 | 73 | "ferrum_1.realm_testApp_clients": [ 74 | { 75 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5207", 76 | "name": "test-test-app-client" 77 | } 78 | ] 79 | } 80 | ``` 81 | 4. Users itself stores in redis by key with a pattern - `{namespace}.{realmName}_user_{userName}`, if we are having user with `admin` userName. Users could have 82 | different structure, but must meet some common user requirements: 83 | * must have `info` object on a `JSON` top level with field `preferred_username` && object `credentials` on the same level as `info`, if user 84 | authenticates with a password than user has to have a password field with a value. 85 | ```json 86 | { 87 | "ferrum_1.myApp_user_vano": { 88 | "info": { 89 | "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 90 | "email_verified": false, 91 | "roles": [ 92 | "admin" 93 | ], 94 | "name": "admin sys", 95 | "preferred_username": "admin", 96 | "given_name": "admin", 97 | "family_name": "sys" 98 | }, 99 | "credentials": { 100 | "password": "1s2d3f4g90xs" 101 | } 102 | }, 103 | "ferrum_1.myApp_user_admin": { 104 | "info": { 105 | "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0724", 106 | "email_verified": false, 107 | "roles": [ 108 | "admin" 109 | ], 110 | "name": "admin sys", 111 | "preferred_username": "admin", 112 | "given_name": "admin", 113 | "family_name": "sys" 114 | }, 115 | "credentials": { 116 | "password": "1s2d3f4g90xs" 117 | } 118 | } 119 | } 120 | ``` 121 | 5. Users to Realms binding in Redis object by the following key pattern - `{namespace}.realm_{realmName}_users` 122 | ```json 123 | { 124 | "ferrum_1.realm_myApp_users": [ 125 | { 126 | "id": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 127 | "name": "vano" 128 | }, 129 | { 130 | "id": "667ff6a7-3f6b-449b-a217-6fc5d9ac0724", 131 | "name": "admin" 132 | } 133 | ] 134 | } 135 | ``` -------------------------------------------------------------------------------- /testData/redis/insert_test_data.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import redis 4 | from redis.commands.json.path import Path 5 | 6 | redis_host = 'redis' 7 | redis_port = 6379 8 | db = 0 9 | username = os.environ['REDIS_USER'] 10 | password = os.environ['REDIS_PASSWORD'] 11 | client = redis.Redis(host=redis_host, port=redis_port, db=db, username=username, password=password) 12 | try: 13 | response = client.ping() 14 | except redis.ConnectionError: 15 | print('Bad connect to redis, host - "{}".'.format(redis_host)) 16 | 17 | isExistsRealm = client.exists("ferrum_1.realm_myApp") 18 | if isExistsRealm: 19 | print('The radis has "ferrum_1.realm_myApp". Data not inserted during initialization.') 20 | exit() 21 | 22 | 23 | realm_myApp = { 24 | "name": "myApp", 25 | "token_expiration": 600, 26 | "refresh_expiration": 300, 27 | "clients": [ 28 | ] 29 | } 30 | realm_testApp = { 31 | "name": "testapp", 32 | "token_expiration": 6000, 33 | "refresh_expiration": 3000, 34 | "clients": [ 35 | ] 36 | } 37 | realm_myApp = json.dumps(realm_myApp) 38 | realm_testApp = json.dumps(realm_testApp) 39 | client.set('ferrum_1.realm_myApp', realm_myApp) 40 | client.set('ferrum_1.realm_testApp', realm_testApp) 41 | 42 | client_service = { 43 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5a14", 44 | "name": "test-service-app-client", 45 | "type": "confidential", 46 | "auth": { 47 | "type": 1, 48 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz" 49 | } 50 | } 51 | client_mobile = { 52 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5199", 53 | "name": "test-mobile-app-client", 54 | "type": "confidential", 55 | "auth": { 56 | "type": 1, 57 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz" 58 | } 59 | } 60 | client_test = { 61 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5207", 62 | "name": "test-test-app-client", 63 | "type": "confidential", 64 | "auth": { 65 | "type": 1, 66 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz" 67 | } 68 | } 69 | client_service = json.dumps(client_service) 70 | client_mobile = json.dumps(client_mobile) 71 | client_test = json.dumps(client_test) 72 | client.set('ferrum_1.myApp_client_test-service-app-client', client_service) 73 | client.set('ferrum_1.myApp_client_test-mobile-app-client', client_mobile) 74 | client.set('ferrum_1.testApp_client_test-test-app-client', client_test) 75 | 76 | realm_myApp_clients = [ 77 | { 78 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5a14", 79 | "name": "test-service-app-client" 80 | }, 81 | { 82 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5199", 83 | "name": "test-mobile-app-client" 84 | } 85 | ] 86 | realm_testApp_clients = [ 87 | { 88 | "id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e5207", 89 | "name": "test-test-app-client" 90 | } 91 | ] 92 | realm_myApp_clients = [json.dumps(realm_myApp_clients)] 93 | realm_testApp_clients = [json.dumps(realm_testApp_clients)] 94 | client.rpush('ferrum_1.realm_myApp_clients', *realm_myApp_clients) 95 | client.rpush('ferrum_1.realm_testApp_clients', *realm_testApp_clients) 96 | 97 | user_vano = { 98 | "info": { 99 | "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 100 | "email_verified": False, 101 | "roles": [ 102 | "admin" 103 | ], 104 | "name": "admin sys", 105 | "preferred_username": "admin", 106 | "given_name": "admin", 107 | "family_name": "sys" 108 | }, 109 | "credentials": { 110 | "password": "1s2d3f4g90xs" 111 | } 112 | } 113 | user_admin = { 114 | "info": { 115 | "sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac0724", 116 | "email_verified": False, 117 | "roles": [ 118 | "admin" 119 | ], 120 | "name": "admin sys", 121 | "preferred_username": "admin", 122 | "given_name": "admin", 123 | "family_name": "sys" 124 | }, 125 | "credentials": { 126 | "password": "1s2d3f4g90xs" 127 | } 128 | } 129 | user_vano = json.dumps(user_vano) 130 | user_admin = json.dumps(user_admin) 131 | client.set('ferrum_1.myApp_user_vano', user_vano) 132 | client.set('ferrum_1.myApp_user_admin', user_admin) 133 | 134 | realm_myApp_users = [ 135 | { 136 | "id": "667ff6a7-3f6b-449b-a217-6fc5d9ac0723", 137 | "name": "vano" 138 | }, 139 | { 140 | "id": "667ff6a7-3f6b-449b-a217-6fc5d9ac0724", 141 | "name": "admin" 142 | } 143 | ] 144 | realm_myApp_users = [json.dumps(realm_myApp_users)] 145 | client.rpush('ferrum_1.realm_myApp_users', *realm_myApp_users) 146 | 147 | print('Data is inserted into the radis during initialization.') 148 | -------------------------------------------------------------------------------- /testData/requests/wissance.ferrum.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "66ffb895-5dc5-49a0-b853-96c952f9f67e", 4 | "name": "wissance", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "ferrum", 10 | "item": [ 11 | { 12 | "name": "create token", 13 | "request": { 14 | "method": "POST", 15 | "header": [], 16 | "body": { 17 | "mode": "urlencoded", 18 | "urlencoded": [ 19 | { 20 | "key": "username", 21 | "value": "admin", 22 | "type": "text" 23 | }, 24 | { 25 | "key": "password", 26 | "value": "1s2d3f4g90xs", 27 | "type": "text" 28 | }, 29 | { 30 | "key": "client_id", 31 | "value": "test-service-app-client", 32 | "type": "text" 33 | }, 34 | { 35 | "key": "client_secret", 36 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz", 37 | "type": "text" 38 | }, 39 | { 40 | "key": "grant_type", 41 | "value": "password", 42 | "type": "text" 43 | }, 44 | { 45 | "key": "scope", 46 | "value": "profile", 47 | "type": "text" 48 | } 49 | ] 50 | }, 51 | "url": { 52 | "raw": "http://localhost:8182/auth/realms/myapp/protocol/openid-connect/token", 53 | "protocol": "http", 54 | "host": [ 55 | "localhost" 56 | ], 57 | "port": "8182", 58 | "path": [ 59 | "auth", 60 | "realms", 61 | "myapp", 62 | "protocol", 63 | "openid-connect", 64 | "token" 65 | ] 66 | } 67 | }, 68 | "response": [] 69 | }, 70 | { 71 | "name": "get userinfo", 72 | "request": { 73 | "method": "GET", 74 | "header": [ 75 | { 76 | "key": "Authorization", 77 | "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIyMDIzLTA5LTI1VDExOjQ4OjI4LjUyNjUyNDMrMDU6MDAiLCJleHAiOiIyMDIzLTA5LTI1VDEyOjAzOjA5LjE1NDE2NTMrMDU6MDAiLCJqdGkiOiIwMzg5YWMzMC01MzExLTQxNWItOTYwZS02Zjg1YWI5YjNiOWIiLCJ0eXAiOiJCZWFyZXIiLCJpc3MiOiIvaHR0cC9sb2NhbGhvc3Q6ODE4Mi9hdXRoL3JlYWxtcy9teUFwcCIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI2NjdmZjZhNy0zZjZiLTQ0OWItYTIxNy02ZmM1ZDlhYzA3MjQiLCJzZXNzaW9uX3N0YXRlIjoiMWIyNmQ4MWQtNWQ5Ny00ZjA1LWFlZjUtOTEzMjYyOWI4YWU5Iiwic2lkIjoiMWIyNmQ4MWQtNWQ5Ny00ZjA1LWFlZjUtOTEzMjYyOWI4YWU5Iiwic2NvcGUiOiJwcm9maWxlIGVtYWlsIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJmYW1pbHlfbmFtZSI6InN5cyIsImdpdmVuX25hbWUiOiJhZG1pbiIsIm5hbWUiOiJhZG1pbiBzeXMiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sInN1YiI6IjY2N2ZmNmE3LTNmNmItNDQ5Yi1hMjE3LTZmYzVkOWFjMDcyNCJ9.wxKuAhO3UDsZEtHAKSO-SBuPqZNtUyQlPIoFfgKcDAA", 78 | "type": "text" 79 | } 80 | ], 81 | "url": { 82 | "raw": "http://localhost:8182/auth/realms/myApp/protocol/openid-connect/userinfo", 83 | "protocol": "http", 84 | "host": [ 85 | "localhost" 86 | ], 87 | "port": "8182", 88 | "path": [ 89 | "auth", 90 | "realms", 91 | "myApp", 92 | "protocol", 93 | "openid-connect", 94 | "userinfo" 95 | ] 96 | } 97 | }, 98 | "response": [] 99 | }, 100 | { 101 | "name": "refresh token", 102 | "request": { 103 | "method": "POST", 104 | "header": [], 105 | "body": { 106 | "mode": "urlencoded", 107 | "urlencoded": [ 108 | { 109 | "key": "client_id", 110 | "value": "test-service-app-client", 111 | "type": "text" 112 | }, 113 | { 114 | "key": "client_secret", 115 | "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wplYz", 116 | "type": "text" 117 | }, 118 | { 119 | "key": "grant_type", 120 | "value": "refresh_token", 121 | "type": "text" 122 | }, 123 | { 124 | "key": "refresh_token", 125 | "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIyMDIzLTA5LTI1VDExOjQ4OjI4LjUyNjUyNDMrMDU6MDAiLCJleHAiOiIyMDIzLTA5LTI1VDExOjU4OjI4LjUyNjUyNDMrMDU6MDAiLCJqdGkiOiI5YWUyNjliZi03ZWE0LTRlZTEtYWQ0Mi00ZDY5YTQwNTZiNWEiLCJ0eXAiOiJSZWZyZXNoIiwiaXNzIjoiL2h0dHAvbG9jYWxob3N0OjgxODIvYXV0aC9yZWFsbXMvbXlBcHAiLCJhdWQiOiIvaHR0cC9sb2NhbGhvc3Q6ODE4Mi9hdXRoL3JlYWxtcy9teUFwcCIsInN1YiI6IjY2N2ZmNmE3LTNmNmItNDQ5Yi1hMjE3LTZmYzVkOWFjMDcyNCIsInNlc3Npb25fc3RhdGUiOiIxYjI2ZDgxZC01ZDk3LTRmMDUtYWVmNS05MTMyNjI5YjhhZTkiLCJzaWQiOiIxYjI2ZDgxZC01ZDk3LTRmMDUtYWVmNS05MTMyNjI5YjhhZTkiLCJzY29wZSI6InByb2ZpbGUgZW1haWwifQ.IokvCzPe4mhr4IncXVrjj7X1qDvQekGV8bNonfuVkYU", 126 | "type": "text" 127 | } 128 | ] 129 | }, 130 | "url": { 131 | "raw": "http://localhost:8182/auth/realms/myApp/protocol/openid-connect/token", 132 | "protocol": "http", 133 | "host": [ 134 | "localhost" 135 | ], 136 | "port": "8182", 137 | "path": [ 138 | "auth", 139 | "realms", 140 | "myApp", 141 | "protocol", 142 | "openid-connect", 143 | "token" 144 | ] 145 | } 146 | }, 147 | "response": [] 148 | }, 149 | { 150 | "name": "Introspect token", 151 | "request": { 152 | "method": "POST", 153 | "header": [ 154 | { 155 | "key": "Authorization", 156 | "value": "Basic dGVzdC1zZXJ2aWNlLWFwcC1jbGllbnQ6ZmI2WjRSc09hZFZ5Y1FvZVFpTjU3eHB1OHc4d3BsWXo=", 157 | "type": "text" 158 | } 159 | ], 160 | "body": { 161 | "mode": "urlencoded", 162 | "urlencoded": [ 163 | { 164 | "key": "token", 165 | "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIyMDIzLTA5LTI1VDEyOjI3OjUyLjkxMzQ4ODQrMDU6MDAiLCJleHAiOiIyMDIzLTA5LTI1VDEyOjM3OjUyLjkxMzQ4ODQrMDU6MDAiLCJqdGkiOiJkZjk4NzVlMi05MzVhLTRhMTItYThhMi0wYWM1YTE4MTNiZWQiLCJ0eXAiOiJCZWFyZXIiLCJpc3MiOiIvaHR0cC9sb2NhbGhvc3Q6ODE4Mi9hdXRoL3JlYWxtcy9teUFwcCIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI2NjdmZjZhNy0zZjZiLTQ0OWItYTIxNy02ZmM1ZDlhYzA3MjQiLCJzZXNzaW9uX3N0YXRlIjoiM2VkNjU2YWUtYWE1NS00YjViLTk1YjQtNjU0MTk3ZTYwNWFmIiwic2lkIjoiM2VkNjU2YWUtYWE1NS00YjViLTk1YjQtNjU0MTk3ZTYwNWFmIiwic2NvcGUiOiJwcm9maWxlIGVtYWlsIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJmYW1pbHlfbmFtZSI6InN5cyIsImdpdmVuX25hbWUiOiJhZG1pbiIsIm5hbWUiOiJhZG1pbiBzeXMiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sInN1YiI6IjY2N2ZmNmE3LTNmNmItNDQ5Yi1hMjE3LTZmYzVkOWFjMDcyNCJ9.JnhWveW9QBEZBvfdtxm74l5i9Gt9CrH9zj2rLabGe04", 166 | "type": "text" 167 | } 168 | ] 169 | }, 170 | "url": { 171 | "raw": "http://localhost:8182/auth/realms/myApp/protocol/openid-connect/token/introspect", 172 | "protocol": "http", 173 | "host": [ 174 | "localhost" 175 | ], 176 | "port": "8182", 177 | "path": [ 178 | "auth", 179 | "realms", 180 | "myApp", 181 | "protocol", 182 | "openid-connect", 183 | "token", 184 | "introspect" 185 | ] 186 | } 187 | }, 188 | "response": [] 189 | } 190 | ] 191 | } 192 | ] 193 | } -------------------------------------------------------------------------------- /tools/create_wissance_demo_users.ps1: -------------------------------------------------------------------------------- 1 | # Win init CMD 2 | ./ferrum-admin.exe --resource=realm --operation=create --value='{\"name\": \"WissanceFerrumDemo\", \"token_expiration\": 600, \"refresh_expiration\": 300}' 3 | ./ferrum-admin.exe --resource=client --operation=create --value='{\"id\": \"d4dc483d-7d0d-4d2e-a0a0-2d34b55e6666\", \"name\": \"WissanceWebDemo\", \"type\": \"confidential\", \"auth\": {\"type\": 1, \"value\": \"fb6Z4RsOadVycQoeQiN57xpu8w8wTEST\"}}' --params="WissanceFerrumDemo" 4 | ./ferrum-admin.exe --resource=user --operation=create --value='{\"info\": {\"sub\": \"667ff6a7-3f6b-449b-a217-6fc5d9ac6890\", \"email_verified\": true, \"roles\": [\"admin\"], \"name\": \"M.V.Ushakov\", \"preferred_username\": \"umv\", \"given_name\": \"Michael\", \"family_name\": \"Ushakov\"}, \"credentials\": {\"password\": \"1s2d3f4g90xs\"}}' --params="WissanceFerrumDemo" -------------------------------------------------------------------------------- /tools/create_wissance_demo_users_docker.sh: -------------------------------------------------------------------------------- 1 | # Realm WissanceFerrumDemo 2 | ./ferrum-admin --config=config_docker_w_redis.json --resource=realm --operation=create --value='{"name": "WissanceFerrumDemo", "user_federation_services":[], "token_expiration": 600, "refresh_expiration": 300}' 3 | ./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" 4 | ./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" 5 | # Realm WissanceFerrumDemo2 6 | ./ferrum-admin --config=config_docker_w_redis.json --resource=realm --operation=create --value='{"name": "WissanceFerrumDemo2", "user_federation_services":[], "token_expiration": 600, "refresh_expiration": 300}' 7 | ./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" 8 | ./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" 9 | -------------------------------------------------------------------------------- /tools/docker_app_runner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # BE careful if you got error "./docker_app_runner.sh: cannot execute: required file not found" it 100% means that you MUST REPLACE ALL LINE ENDINGS \r\n -> \n 3 | #TODO(UMV): make Smart insert of initial data 4 | ./create_wissance_demo_users_docker.sh || true 5 | ./ferrum --config /app/config_docker_w_redis.json $FERRUM_ADDITIONAL_OPTS 6 | -------------------------------------------------------------------------------- /tools/init_script.sh: -------------------------------------------------------------------------------- 1 | echo "Start init data..." 2 | ./ferrum-admin --config=config_docker_w_redis.json --resource=realm --operation=create --value='{"name": "WissanceFerrumDemo", "token_expiration": 600, "refresh_expiration": 300}' 3 | ./ferrum-admin --config=config_docker_w_redis.json --resource=client --operation=create --params="WissanceFerrumDemo" --value='{"id": "d4dc483d-7d0d-4d2e-a0a0-2d34b55e6666", "name": "WissanceWebDemo", "type": "confidential", "auth": {"type": 1, "value": "fb6Z4RsOadVycQoeQiN57xpu8w8wTEST"}}' 4 | ./ferrum-admin --config=config_docker_w_redis.json --resource=user --operation=create --params="WissanceFerrumDemo" --value='{"info": {"sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac6890", "email_verified": true, "roles": ["admin"], "name": "J.Doe", "preferred_username": "jodo", "given_name": "John", "family_name": "Doe"}, "credentials": {"password": "1s2d3f4g90xs"}}' 5 | ./ferrum-admin --config=config_docker_w_redis.json --resource=user --operation=create --params="WissanceFerrumDemo" --value='{"info": {"sub": "667ff6a7-3f6b-449b-a217-6fc5d9ac6891", "email_verified": true, "roles": ["admin"], "name": "J.Doe", "preferred_username": "jado", "given_name": "Jane", "family_name": "Doe"}, "credentials": {"password": "1s2d3f4g90xs"}}' 6 | echo "End init data." -------------------------------------------------------------------------------- /utils/encoding/encoding.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "crypto/sha512" 5 | "encoding/base64" 6 | "hash" 7 | "math/rand" 8 | ) 9 | 10 | type PasswordJsonEncoder struct { 11 | salt string 12 | hasher hash.Hash 13 | } 14 | 15 | func NewPasswordJsonEncoder(salt string) *PasswordJsonEncoder { 16 | encoder := PasswordJsonEncoder{ 17 | hasher: sha512.New(), 18 | salt: salt, 19 | } 20 | return &encoder 21 | } 22 | 23 | func (e *PasswordJsonEncoder) GetB64PasswordHash(password string) string { 24 | passwordBytes := []byte(password + e.salt) 25 | e.hasher.Write(passwordBytes) 26 | hashedPasswordBytes := e.hasher.Sum(nil) 27 | e.hasher.Reset() 28 | 29 | b64encoded := b64Encode(hashedPasswordBytes) 30 | return b64encoded 31 | } 32 | 33 | func (e *PasswordJsonEncoder) IsPasswordsMatch(password, hash string) bool { 34 | currPasswordHash := e.GetB64PasswordHash(password) 35 | return b64Decode(hash) == b64Decode(currPasswordHash) 36 | } 37 | 38 | func GenerateRandomSalt() string { 39 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+=" 40 | salt := make([]byte, 32) 41 | for i := range salt { 42 | salt[i] = charset[rand.Intn(len(charset))] 43 | } 44 | return string(salt) 45 | } 46 | 47 | func b64Encode(encoded []byte) string { 48 | cstr := base64.URLEncoding.EncodeToString(encoded) 49 | return cstr 50 | } 51 | 52 | func b64Decode(encoded string) string { 53 | cstr, err := base64.URLEncoding.DecodeString(encoded) 54 | if err != nil { 55 | return "" 56 | } 57 | return string(cstr) 58 | } 59 | -------------------------------------------------------------------------------- /utils/encoding/encoding_test.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_HashPassword(t *testing.T) { 10 | t.Run("success", func(t *testing.T) { 11 | // Arrange 12 | pwd := "qwerty" 13 | salt := "salt" 14 | encoder := NewPasswordJsonEncoder(salt) 15 | 16 | // Act 17 | hashedPwd := encoder.GetB64PasswordHash(pwd) 18 | isMatch := encoder.IsPasswordsMatch(pwd, hashedPwd) 19 | 20 | // Assert 21 | assert.True(t, isMatch) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /utils/jsontools/json_merge.go: -------------------------------------------------------------------------------- 1 | package jsontools 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | ) 7 | 8 | func MergeNonIntersect[T1 any, T2 any](first *T1, second *T2) (interface{}, string) { 9 | var result interface{} 10 | str1, err := json.Marshal(&first) 11 | if err != nil { 12 | // todo(UMV): add trouble logging 13 | return nil, "" 14 | } 15 | str2, err := json.Marshal(&second) 16 | if err != nil { 17 | // todo(UMV): add trouble logging 18 | return nil, "" 19 | } 20 | // trim } from end of str1 21 | str1 = []byte(strings.TrimRight(string(str1), "}")) 22 | 23 | // trim { from start of str2 24 | str2 = []byte(strings.TrimLeft(string(str2), "{")) 25 | str := string(str1) + "," + string(str2) 26 | 27 | err = json.Unmarshal([]byte(str), &result) 28 | if err != nil { 29 | // todo(UMV): add trouble logging 30 | return nil, "" 31 | } 32 | return result, str 33 | } 34 | -------------------------------------------------------------------------------- /utils/transformers/redis_cfg_transformer.go: -------------------------------------------------------------------------------- 1 | package transformers 2 | 3 | import ( 4 | "crypto/tls" 5 | "github.com/redis/go-redis/v9" 6 | "github.com/wissance/Ferrum/config" 7 | ) 8 | 9 | // TransformRedisConfig functions that transforms internal config to redis.Options that is required to establish connection 10 | func TransformRedisConfig(redisCfg *config.RedisConfig) (*redis.Options, error) { 11 | // 1. Creation of minimal required to connect options (address, db, password) 12 | opts := redis.Options{ 13 | Addr: redisCfg.Address, 14 | Password: redisCfg.Password, 15 | DB: redisCfg.DbNumber, 16 | } 17 | 18 | // 2. Configure pool 19 | opts.PoolFIFO = true 20 | opts.PoolSize = int(redisCfg.PoolSize) 21 | 22 | // 3. Configure timeouts && retry && limitation 23 | 24 | // 4. TLS Configuration (further) 25 | opts.TLSConfig = &tls.Config{} 26 | 27 | return &opts, nil 28 | } 29 | -------------------------------------------------------------------------------- /utils/validators/common.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import "strconv" 4 | 5 | type ValueTypeRequirements string 6 | 7 | const ( 8 | String ValueTypeRequirements = "string" 9 | Integer ValueTypeRequirements = "integer" 10 | Boolean ValueTypeRequirements = "boolean" 11 | StrOrInt ValueTypeRequirements = "str or int" 12 | Any ValueTypeRequirements = "any" 13 | ) 14 | 15 | func IsStrValueOfRequiredType(requirements ValueTypeRequirements, value *string) bool { 16 | if value == nil { 17 | return false 18 | } 19 | if requirements == Any { 20 | return true 21 | } 22 | if requirements == Integer { 23 | _, err := strconv.Atoi(*value) 24 | return err == nil 25 | } 26 | if requirements == Boolean { 27 | _, err := strconv.ParseBool(*value) 28 | return err == nil 29 | } 30 | return true 31 | } 32 | --------------------------------------------------------------------------------