├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
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 |
104 |
105 |
106 |
107 |
114 |
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 | 
6 | 
7 | 
8 | 
9 | 
10 |
11 | 
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 | 
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 |
--------------------------------------------------------------------------------