Skip to content

Commit

Permalink
The authentication (#28)
Browse files Browse the repository at this point in the history
  • Loading branch information
sgmv authored Jan 21, 2025
1 parent 4ba39fc commit 5e2ee3d
Show file tree
Hide file tree
Showing 24 changed files with 1,230 additions and 122 deletions.
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,12 @@
SD_DB=postgresql://pg:pass@localhost:5432/status_dashboard
SD_CACHE=internal
SD_LOG_LEVEL=devel
SD_WEB_URL=http://localhost:9000
SD_HOSTNAME=localhost
SD_SSL_DISABLED=false
SD_PORT=8000
SD_AUTHENTICATION_DISABLED=false
SD_KEYCLOAK_URL=http://localhost:8080
SD_KEYCLOAK_REALM=myapp
SD_KEYCLOAK_CLIENT_ID=myclient
SD_KEYCLOAK_CLIENT_SECRET=secret
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.DS_Store
.idea
*.bkp
*.exe
Expand Down
19 changes: 16 additions & 3 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: "3.8"

services:
database:
container_name: database
Expand All @@ -10,9 +8,24 @@ services:
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=status_dashboard
ports:
- 5432:5432
- "5432:5432"
volumes:
- db:/var/lib/postgresql/data
keycloak:
container_name: keycloak
image: quay.io/keycloak/keycloak:26.0.6
restart: always
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
- KC_HOSTNAME=localhost
- KC_LOG_CONSOLE_LEVEL=all
ports:
- "8080:8080"
volumes:
- keycloak:/opt/keycloak/data/
command: start-dev

volumes:
db:
keycloak:
205 changes: 205 additions & 0 deletions docs/auth/authentication.drawio

Large diffs are not rendered by default.

108 changes: 108 additions & 0 deletions docs/auth/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Authentication

In this section we focus on the authentication for frontend SPA. The main focus here - the security.

For this approach we don't need share any information about keycloak client. The FE doesn't need to know any urls and secrets.

The general schema presented here

[authentication schema source file](./authentication.drawio)

![authentication_schema](./authentication.png)

## Details

### The first step on the frontend part

Our backend expects the base64 encoded JSON object. In this object should be minimum 2 fields: { "callback_url": callbackURL, "code_challenge": codeChallenge }.

The `callback_url` is the url for the redirected page from backend. It's used for redirect after successful authorisation.

The `code_challenge` is the SHA256 hash for `code_verifier` code.

The `code_verifier` is needed for proving the access to the stored access tokens on the last step.

And the `code_verifier` should be stored in the local (or if possible session) browser storage.

Example:

```js
// imagine, the login page is http://frontend_url/login

const originalUrl = window.location.href;
const url = originalUrl.substring(0, originalUrl.indexOf("/login"));

// the callback url for processing the response from backend
const callbackURL = `${url}/callback`

const codeVerifier = generateCodeVerifier()
localStorage.setItem('code_verifier', codeVerifier);

let codeChallenge = CryptoJS.SHA256(codeVerifier).toString(CryptoJS.enc.Hex);
let stateObj = JSON.stringify({ "callback_url": callbackURL, "code_challenge": codeChallenge })

const state = btoa(stateObj).replace(/=+$/, '');

// Redirect to the backend's login endpoint
window.location.href = `http://backend_url/auth/login?state=${state}`;
```

The last line redirects to the backend endpoint, which generates the auth URL and redirects to it.

Example:

```go
func startLogin(c *gin.Context) {
// Generate OAuth2 login URL
state := c.Query("state")
oauthURL := oauth2Config.AuthCodeURL(state)
c.Redirect(http.StatusFound, oauthURL)
}
```

### The tokens processing

After the successful authorisation, the keycloak redirects to the backend callback url with `code` and `state` url query params.
The backend extracts tokens from `code`, the `code_challenge` and `callback_url` from `state` and save the data in the local storage or cache.
The key for tokens is `code_challenge`. After all the backend redirects to the `callback_url`.

### Retrieve data for frontend

After the backend redirected to the frontend `callback_url` the frontend should extract the `code_verifier` from the local or session storage.
Then the frontend should send a POST request to the backend's token url

Example:
```js
handleCallback() {
const codeVerifier = localStorage.getItem("code_verifier");
if (codeVerifier == null) {
console.error("invalid code_verifier");
return;
}

let config = {
headers: {
'Content-Type': 'application/json',
},
};
axios.post("http://backend_url/auth/token", {"code_verifier": codeVerifier}, config)
}
```
The backend calculates the SHA256 for `code_verifier` and extract saved data from cache or local storage. And return user tokens.

# Authentication middleware

On the backend side we check all incoming requests and try to extract `Bearer` header with access token.
After successful extraction we get public keys from keycloak realm. And check the `access_token` by these keys.

# How to get a token locally

```shell
curl -X POST http://localhost:8080/realms/myapp/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=client" \
-d "username=user" \
-d "password=user" \
-d "client_secret=secret"
```
Binary file added docs/auth/authentication.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
## Table of contents

- [Incident creation for API V1](./v1/v1_incident_creation.md)
- [Components availability V2](./v2/v2_components_availability.md)
- [Components availability V2](./v2/v2_components_availability.md)
- [Authentication for FE part](./auth/authentication.md)
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ toolchain go1.22.9

require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/coreos/go-oidc/v3 v3.11.0
github.com/gin-gonic/gin v1.10.0
github.com/joho/godotenv v1.5.1
github.com/kelseyhightower/envconfig v1.4.0
github.com/stretchr/testify v1.10.0
github.com/testcontainers/testcontainers-go v0.34.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0
go.uber.org/zap v1.27.0
golang.org/x/oauth2 v0.24.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
moul.io/zapgorm2 v1.3.0
Expand All @@ -39,6 +41,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
Expand All @@ -47,6 +50,7 @@ require (
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
Expand All @@ -48,6 +50,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand All @@ -67,6 +71,8 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
Expand Down Expand Up @@ -242,6 +248,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down
37 changes: 31 additions & 6 deletions internal/api/api.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,59 @@
package api

import (
"fmt"

"github.com/gin-gonic/gin"
"go.uber.org/zap"

"github.com/stackmon/otc-status-dashboard/internal/api/auth"
"github.com/stackmon/otc-status-dashboard/internal/api/errors"
"github.com/stackmon/otc-status-dashboard/internal/conf"
"github.com/stackmon/otc-status-dashboard/internal/db"
)

type API struct {
r *gin.Engine
db *db.DB
log *zap.Logger
r *gin.Engine
db *db.DB
log *zap.Logger
oa2Prov *auth.Provider
}

func New(cfg *conf.Config, log *zap.Logger, database *db.DB) *API {
func New(cfg *conf.Config, log *zap.Logger, database *db.DB) (*API, error) {
if cfg.LogLevel != conf.DevelopMode {
gin.SetMode(gin.ReleaseMode)
}

oa2Prov := &auth.Provider{Disabled: true}

if !cfg.AuthenticationDisabled {
hostURI := fmt.Sprintf("%s:%s", cfg.Hostname, cfg.Port)

if cfg.SSLDisabled {
hostURI = fmt.Sprintf("http://%s", hostURI)
} else {
hostURI = fmt.Sprintf("https://%s", hostURI)
}

var err error
oa2Prov, err = auth.NewProvider(
cfg.Keycloak.URL, cfg.Keycloak.Realm, cfg.Keycloak.ClientID,
cfg.Keycloak.ClientSecret, hostURI, cfg.WebURL,
)
if err != nil {
return nil, fmt.Errorf("could not initialise the OAuth provider, err: %w", err)
}
}

r := gin.New()
r.Use(Logger(log), gin.Recovery())
r.Use(ErrorHandle())
r.Use(CORSMiddleware())
r.NoRoute(errors.Return404)

a := &API{r: r, db: database, log: log}
a := &API{r: r, db: database, log: log, oa2Prov: oa2Prov}
a.InitRoutes()
return a
return a, nil
}

func (a *API) Router() *gin.Engine {
Expand Down
Loading

0 comments on commit 5e2ee3d

Please sign in to comment.