Skip to content

Commit 5e2ee3d

Browse files
authored
The authentication (#28)
1 parent 4ba39fc commit 5e2ee3d

24 files changed

+1230
-122
lines changed

.env.example

+9
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,12 @@
22
SD_DB=postgresql://pg:pass@localhost:5432/status_dashboard
33
SD_CACHE=internal
44
SD_LOG_LEVEL=devel
5+
SD_WEB_URL=http://localhost:9000
6+
SD_HOSTNAME=localhost
7+
SD_SSL_DISABLED=false
8+
SD_PORT=8000
9+
SD_AUTHENTICATION_DISABLED=false
10+
SD_KEYCLOAK_URL=http://localhost:8080
11+
SD_KEYCLOAK_REALM=myapp
12+
SD_KEYCLOAK_CLIENT_ID=myclient
13+
SD_KEYCLOAK_CLIENT_SECRET=secret

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.DS_Store
12
.idea
23
*.bkp
34
*.exe

docker-compose.yaml

+16-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
version: "3.8"
2-
31
services:
42
database:
53
container_name: database
@@ -10,9 +8,24 @@ services:
108
- POSTGRES_PASSWORD=pass
119
- POSTGRES_DB=status_dashboard
1210
ports:
13-
- 5432:5432
11+
- "5432:5432"
1412
volumes:
1513
- db:/var/lib/postgresql/data
14+
keycloak:
15+
container_name: keycloak
16+
image: quay.io/keycloak/keycloak:26.0.6
17+
restart: always
18+
environment:
19+
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
20+
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
21+
- KC_HOSTNAME=localhost
22+
- KC_LOG_CONSOLE_LEVEL=all
23+
ports:
24+
- "8080:8080"
25+
volumes:
26+
- keycloak:/opt/keycloak/data/
27+
command: start-dev
1628

1729
volumes:
1830
db:
31+
keycloak:

docs/auth/authentication.drawio

+205
Large diffs are not rendered by default.

docs/auth/authentication.md

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Authentication
2+
3+
In this section we focus on the authentication for frontend SPA. The main focus here - the security.
4+
5+
For this approach we don't need share any information about keycloak client. The FE doesn't need to know any urls and secrets.
6+
7+
The general schema presented here
8+
9+
[authentication schema source file](./authentication.drawio)
10+
11+
![authentication_schema](./authentication.png)
12+
13+
## Details
14+
15+
### The first step on the frontend part
16+
17+
Our backend expects the base64 encoded JSON object. In this object should be minimum 2 fields: { "callback_url": callbackURL, "code_challenge": codeChallenge }.
18+
19+
The `callback_url` is the url for the redirected page from backend. It's used for redirect after successful authorisation.
20+
21+
The `code_challenge` is the SHA256 hash for `code_verifier` code.
22+
23+
The `code_verifier` is needed for proving the access to the stored access tokens on the last step.
24+
25+
And the `code_verifier` should be stored in the local (or if possible session) browser storage.
26+
27+
Example:
28+
29+
```js
30+
// imagine, the login page is http://frontend_url/login
31+
32+
const originalUrl = window.location.href;
33+
const url = originalUrl.substring(0, originalUrl.indexOf("/login"));
34+
35+
// the callback url for processing the response from backend
36+
const callbackURL = `${url}/callback`
37+
38+
const codeVerifier = generateCodeVerifier()
39+
localStorage.setItem('code_verifier', codeVerifier);
40+
41+
let codeChallenge = CryptoJS.SHA256(codeVerifier).toString(CryptoJS.enc.Hex);
42+
let stateObj = JSON.stringify({ "callback_url": callbackURL, "code_challenge": codeChallenge })
43+
44+
const state = btoa(stateObj).replace(/=+$/, '');
45+
46+
// Redirect to the backend's login endpoint
47+
window.location.href = `http://backend_url/auth/login?state=${state}`;
48+
```
49+
50+
The last line redirects to the backend endpoint, which generates the auth URL and redirects to it.
51+
52+
Example:
53+
54+
```go
55+
func startLogin(c *gin.Context) {
56+
// Generate OAuth2 login URL
57+
state := c.Query("state")
58+
oauthURL := oauth2Config.AuthCodeURL(state)
59+
c.Redirect(http.StatusFound, oauthURL)
60+
}
61+
```
62+
63+
### The tokens processing
64+
65+
After the successful authorisation, the keycloak redirects to the backend callback url with `code` and `state` url query params.
66+
The backend extracts tokens from `code`, the `code_challenge` and `callback_url` from `state` and save the data in the local storage or cache.
67+
The key for tokens is `code_challenge`. After all the backend redirects to the `callback_url`.
68+
69+
### Retrieve data for frontend
70+
71+
After the backend redirected to the frontend `callback_url` the frontend should extract the `code_verifier` from the local or session storage.
72+
Then the frontend should send a POST request to the backend's token url
73+
74+
Example:
75+
```js
76+
handleCallback() {
77+
const codeVerifier = localStorage.getItem("code_verifier");
78+
if (codeVerifier == null) {
79+
console.error("invalid code_verifier");
80+
return;
81+
}
82+
83+
let config = {
84+
headers: {
85+
'Content-Type': 'application/json',
86+
},
87+
};
88+
axios.post("http://backend_url/auth/token", {"code_verifier": codeVerifier}, config)
89+
}
90+
```
91+
The backend calculates the SHA256 for `code_verifier` and extract saved data from cache or local storage. And return user tokens.
92+
93+
# Authentication middleware
94+
95+
On the backend side we check all incoming requests and try to extract `Bearer` header with access token.
96+
After successful extraction we get public keys from keycloak realm. And check the `access_token` by these keys.
97+
98+
# How to get a token locally
99+
100+
```shell
101+
curl -X POST http://localhost:8080/realms/myapp/protocol/openid-connect/token \
102+
-H "Content-Type: application/x-www-form-urlencoded" \
103+
-d "grant_type=password" \
104+
-d "client_id=client" \
105+
-d "username=user" \
106+
-d "password=user" \
107+
-d "client_secret=secret"
108+
```

docs/auth/authentication.png

142 KB
Loading

docs/readme.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
## Table of contents
44

55
- [Incident creation for API V1](./v1/v1_incident_creation.md)
6-
- [Components availability V2](./v2/v2_components_availability.md)
6+
- [Components availability V2](./v2/v2_components_availability.md)
7+
- [Authentication for FE part](./auth/authentication.md)

go.mod

+4
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ toolchain go1.22.9
66

77
require (
88
github.com/DATA-DOG/go-sqlmock v1.5.2
9+
github.com/coreos/go-oidc/v3 v3.11.0
910
github.com/gin-gonic/gin v1.10.0
1011
github.com/joho/godotenv v1.5.1
1112
github.com/kelseyhightower/envconfig v1.4.0
1213
github.com/stretchr/testify v1.10.0
1314
github.com/testcontainers/testcontainers-go v0.34.0
1415
github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0
1516
go.uber.org/zap v1.27.0
17+
golang.org/x/oauth2 v0.24.0
1618
gorm.io/driver/postgres v1.5.11
1719
gorm.io/gorm v1.25.12
1820
moul.io/zapgorm2 v1.3.0
@@ -39,6 +41,7 @@ require (
3941
github.com/felixge/httpsnoop v1.0.4 // indirect
4042
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
4143
github.com/gin-contrib/sse v0.1.0 // indirect
44+
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
4245
github.com/go-logr/logr v1.4.2 // indirect
4346
github.com/go-logr/stdr v1.2.2 // indirect
4447
github.com/go-ole/go-ole v1.2.6 // indirect
@@ -47,6 +50,7 @@ require (
4750
github.com/go-playground/validator/v10 v10.22.0 // indirect
4851
github.com/goccy/go-json v0.10.3 // indirect
4952
github.com/gogo/protobuf v1.3.2 // indirect
53+
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
5054
github.com/google/uuid v1.6.0 // indirect
5155
github.com/jackc/pgpassfile v1.0.0 // indirect
5256
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect

go.sum

+8
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
2424
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
2525
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
2626
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
27+
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
28+
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
2729
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
2830
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
2931
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -48,6 +50,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
4850
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
4951
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
5052
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
53+
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
54+
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
5155
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
5256
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
5357
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -67,6 +71,8 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
6771
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
6872
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
6973
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
74+
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
75+
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
7076
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
7177
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
7278
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -242,6 +248,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
242248
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
243249
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
244250
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
251+
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
252+
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
245253
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
246254
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
247255
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

internal/api/api.go

+31-6
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,59 @@
11
package api
22

33
import (
4+
"fmt"
5+
46
"github.com/gin-gonic/gin"
57
"go.uber.org/zap"
68

9+
"github.com/stackmon/otc-status-dashboard/internal/api/auth"
710
"github.com/stackmon/otc-status-dashboard/internal/api/errors"
811
"github.com/stackmon/otc-status-dashboard/internal/conf"
912
"github.com/stackmon/otc-status-dashboard/internal/db"
1013
)
1114

1215
type API struct {
13-
r *gin.Engine
14-
db *db.DB
15-
log *zap.Logger
16+
r *gin.Engine
17+
db *db.DB
18+
log *zap.Logger
19+
oa2Prov *auth.Provider
1620
}
1721

18-
func New(cfg *conf.Config, log *zap.Logger, database *db.DB) *API {
22+
func New(cfg *conf.Config, log *zap.Logger, database *db.DB) (*API, error) {
1923
if cfg.LogLevel != conf.DevelopMode {
2024
gin.SetMode(gin.ReleaseMode)
2125
}
2226

27+
oa2Prov := &auth.Provider{Disabled: true}
28+
29+
if !cfg.AuthenticationDisabled {
30+
hostURI := fmt.Sprintf("%s:%s", cfg.Hostname, cfg.Port)
31+
32+
if cfg.SSLDisabled {
33+
hostURI = fmt.Sprintf("http://%s", hostURI)
34+
} else {
35+
hostURI = fmt.Sprintf("https://%s", hostURI)
36+
}
37+
38+
var err error
39+
oa2Prov, err = auth.NewProvider(
40+
cfg.Keycloak.URL, cfg.Keycloak.Realm, cfg.Keycloak.ClientID,
41+
cfg.Keycloak.ClientSecret, hostURI, cfg.WebURL,
42+
)
43+
if err != nil {
44+
return nil, fmt.Errorf("could not initialise the OAuth provider, err: %w", err)
45+
}
46+
}
47+
2348
r := gin.New()
2449
r.Use(Logger(log), gin.Recovery())
2550
r.Use(ErrorHandle())
2651
r.Use(CORSMiddleware())
2752
r.NoRoute(errors.Return404)
2853

29-
a := &API{r: r, db: database, log: log}
54+
a := &API{r: r, db: database, log: log, oa2Prov: oa2Prov}
3055
a.InitRoutes()
31-
return a
56+
return a, nil
3257
}
3358

3459
func (a *API) Router() *gin.Engine {

0 commit comments

Comments
 (0)