diff --git a/.env.example b/.env.example
index f2f6642..f9829a1 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
index 6db5d87..3d8884e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.DS_Store
.idea
*.bkp
*.exe
diff --git a/docker-compose.yaml b/docker-compose.yaml
index a76bfcd..1f5ed1e 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -1,5 +1,3 @@
-version: "3.8"
-
services:
database:
container_name: database
@@ -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:
diff --git a/docs/auth/authentication.drawio b/docs/auth/authentication.drawio
new file mode 100644
index 0000000..16050f9
--- /dev/null
+++ b/docs/auth/authentication.drawio
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/auth/authentication.md b/docs/auth/authentication.md
new file mode 100644
index 0000000..2a2ec3c
--- /dev/null
+++ b/docs/auth/authentication.md
@@ -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"
+```
\ No newline at end of file
diff --git a/docs/auth/authentication.png b/docs/auth/authentication.png
new file mode 100644
index 0000000..f572eb6
Binary files /dev/null and b/docs/auth/authentication.png differ
diff --git a/docs/readme.md b/docs/readme.md
index 3428776..877cbec 100644
--- a/docs/readme.md
+++ b/docs/readme.md
@@ -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)
\ No newline at end of file
+- [Components availability V2](./v2/v2_components_availability.md)
+- [Authentication for FE part](./auth/authentication.md)
diff --git a/go.mod b/go.mod
index 8c434f8..3305e06 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ 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
@@ -13,6 +14,7 @@ require (
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
@@ -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
@@ -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
diff --git a/go.sum b/go.sum
index 6a76ccd..cafba63 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
@@ -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=
@@ -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=
@@ -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=
diff --git a/internal/api/api.go b/internal/api/api.go
index 3f4b512..7a99502 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -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 {
diff --git a/internal/api/auth/auth.go b/internal/api/auth/auth.go
new file mode 100644
index 0000000..da7a2a6
--- /dev/null
+++ b/internal/api/auth/auth.go
@@ -0,0 +1,229 @@
+package auth
+
+import (
+ "context"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+ "golang.org/x/oauth2"
+
+ apiErrors "github.com/stackmon/otc-status-dashboard/internal/api/errors"
+)
+
+const (
+ authCallbackURL = "auth/callback"
+)
+
+type Provider struct {
+ Disabled bool
+ WebURL string
+ kc *Keycloak
+ conf *oauth2.Config
+ storage *internalStorage
+ realmPublicKey *rsa.PublicKey
+}
+
+func NewProvider(
+ keycloakBaseURL,
+ keycloakRealm,
+ keycloakClientID,
+ keycloakClientSecret,
+ hostname,
+ webURL string,
+) (*Provider, error) {
+ kc := NewKeycloak(keycloakBaseURL, keycloakRealm, keycloakClientID, keycloakClientSecret)
+
+ redirectURI := fmt.Sprintf("%s/%s", hostname, authCallbackURL)
+
+ conf := &oauth2.Config{
+ ClientID: keycloakClientID,
+ ClientSecret: keycloakClientSecret,
+ RedirectURL: redirectURI,
+ Endpoint: kc.Endpoint(),
+ Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
+ }
+ return &Provider{
+ WebURL: webURL,
+ kc: kc,
+ conf: conf,
+ storage: newInternalStorage(),
+ }, nil
+}
+
+func (p *Provider) AuthCodeURL(state string) string {
+ return p.conf.AuthCodeURL(state)
+}
+
+func (p *Provider) Exchange(ctx context.Context, code string) (*oauth2.Token, error) {
+ return p.conf.Exchange(ctx, code)
+}
+
+func (p *Provider) PutToken(key string, token TokenRepr) {
+ p.storage.Store(key, token)
+}
+
+func (p *Provider) GetToken(key string) (TokenRepr, bool) {
+ token, ok := p.storage.Get(key)
+ if !ok {
+ return TokenRepr{}, false
+ }
+ p.storage.Delete(key)
+ return token, true
+}
+
+func (p *Provider) GetPublicKey() (*rsa.PublicKey, error) {
+ if p.realmPublicKey != nil {
+ return p.realmPublicKey, nil
+ }
+
+ pKey, err := p.kc.fetchPublicKey()
+ if err != nil {
+ return nil, err
+ }
+ p.realmPublicKey = pKey
+ return pKey, nil
+}
+
+func (p *Provider) revokeToken(refreshToken string) error {
+ return p.kc.revokeToken(refreshToken)
+}
+
+func GetLoginPageHandler(prov *Provider, logger *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ logger.Info("start to process login page request")
+ state := c.Query("state")
+ if state == "" {
+ apiErrors.RaiseBadRequestErr(c, apiErrors.ErrAuthMissedStateParam)
+ return
+ }
+
+ oauthURL := prov.AuthCodeURL(state)
+ logger.Info("redirect to keycloak login page")
+ c.Redirect(http.StatusSeeOther, oauthURL)
+ }
+}
+
+type StatePayload struct {
+ CallbackURL string `json:"callback_url"`
+ CodeChallenge string `json:"code_challenge"`
+}
+
+type TokenRepr struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+}
+
+// GetCallbackHandler is a handler for the callback from the Keycloak, it redirects to the FE url.
+func GetCallbackHandler(prov *Provider, logger *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ logger.Info("start to process authentication callback from keycloak")
+ code := c.Query("code")
+ state := c.Query("state")
+
+ stateDecode, err := base64.RawStdEncoding.DecodeString(state)
+ if err != nil {
+ logger.Error("failed to decode base64 for state", zap.Error(err), zap.String("state", state))
+ c.SetCookie("error", apiErrors.ErrAuthValidateBase64State.Error(), 1, "/", "", false, false)
+ c.Redirect(http.StatusBadRequest, prov.WebURL)
+ return
+ }
+
+ statePayload := &StatePayload{}
+ err = json.Unmarshal(stateDecode, statePayload)
+ if err != nil {
+ logger.Error(
+ "failed to unmarshal state to a struct", zap.Error(err), zap.String("state_decode", string(stateDecode)),
+ )
+ c.SetCookie("error", apiErrors.ErrAuthValidateBase64State.Error(), 1, "/", "", false, false)
+ c.Redirect(http.StatusBadRequest, prov.WebURL)
+ return
+ }
+
+ logger.Info("try to exchange code for tokens")
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*defaultTimeout)
+ defer cancel()
+ token, err := prov.Exchange(ctx, code)
+ if err != nil {
+ logger.Error("failed to exchange a code to a tokens", zap.Error(err), zap.String("code", code))
+ c.SetCookie("error", apiErrors.ErrAuthExchangeToken.Error(), 1, "/", "", false, false)
+ c.Redirect(http.StatusBadRequest, statePayload.CallbackURL)
+ return
+ }
+
+ prov.PutToken(statePayload.CodeChallenge, TokenRepr{AccessToken: token.AccessToken, RefreshToken: token.RefreshToken})
+ logger.Info("redirect to the client callback url")
+ c.Redirect(http.StatusSeeOther, statePayload.CallbackURL)
+ }
+}
+
+type CodeVerifierReq struct {
+ CodeVerifier string `json:"code_verifier"`
+}
+
+func PostTokenHandler(prov *Provider, logger *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ logger.Info("start to process token request")
+ codeVerifier := CodeVerifierReq{}
+ err := c.ShouldBindBodyWithJSON(&codeVerifier)
+ if err != nil {
+ apiErrors.RaiseBadRequestErr(c, apiErrors.ErrAuthWrongCodeVerifier)
+ return
+ }
+
+ h := sha256.New()
+ h.Write([]byte(codeVerifier.CodeVerifier))
+ codeChallenge := hex.EncodeToString(h.Sum(nil))
+
+ logger.Debug("try to get token from the storage")
+ token, ok := prov.GetToken(codeChallenge)
+ if !ok {
+ apiErrors.RaiseBadRequestErr(c, apiErrors.ErrAuthMissingDataForCodeVerifier)
+ return
+ }
+ logger.Info("return token to the client")
+ c.JSON(http.StatusOK, token)
+ }
+}
+
+type PutLogoutReq struct {
+ RefreshToken string `json:"refresh_token"`
+}
+
+func PutLogoutHandler(prov *Provider, logger *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ logger.Debug("start to process logout request")
+
+ var req PutLogoutReq
+ err := c.ShouldBindBodyWithJSON(&req)
+ if err != nil {
+ apiErrors.RaiseBadRequestErr(c, apiErrors.ErrAuthMissingRefreshToken)
+ return
+ }
+
+ err = prov.revokeToken(req.RefreshToken)
+ if err != nil {
+ var keycloakErrorResponse KeycloakExternalError
+ switch {
+ case errors.As(err, &keycloakErrorResponse):
+ apiErrors.RaiseBadRequestErr(c, keycloakErrorResponse)
+ default:
+ logger.Error("failed to revoke token", zap.Error(err))
+ apiErrors.RaiseInternalErr(c, apiErrors.ErrAuthFailedLogout)
+ }
+
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+ }
+}
diff --git a/internal/api/auth/keycloak.go b/internal/api/auth/keycloak.go
new file mode 100644
index 0000000..be43c98
--- /dev/null
+++ b/internal/api/auth/keycloak.go
@@ -0,0 +1,156 @@
+package auth
+
+import (
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "golang.org/x/oauth2"
+)
+
+const defaultTimeout = 10
+
+type Keycloak struct {
+ httpClient *http.Client
+
+ clientID string
+ clientSecret string
+
+ issuer string
+ authURL string
+ tokenURL string
+ deviceAuthURL string
+ userInfoURL string
+ jwksURL string
+ logoutURL string
+}
+
+func NewKeycloak(url, realm, clientID, clientSecret string) *Keycloak {
+ // you can get all endpoint from endpoint realms/{realm}/.well-known/openid-configuration
+ issuer := fmt.Sprintf("%s/realms/%s", url, realm)
+ authURL := fmt.Sprintf("%s/protocol/openid-connect/auth", issuer)
+ tokenURL := fmt.Sprintf("%s/protocol/openid-connect/token", issuer)
+ deviceAuthURL := fmt.Sprintf("%s/protocol/openid-connect/auth/device", issuer)
+ userInfoURL := fmt.Sprintf("%s/protocol/openid-connect/userinfo", issuer)
+ jwksURL := fmt.Sprintf("%s/protocol/openid-connect/certs", issuer)
+ logoutURL := fmt.Sprintf("%s/protocol/openid-connect/logout", issuer)
+
+ httpClient := &http.Client{
+ Timeout: time.Second * defaultTimeout,
+ }
+
+ return &Keycloak{
+ httpClient: httpClient,
+
+ clientID: clientID,
+ clientSecret: clientSecret,
+
+ issuer: issuer,
+ authURL: authURL,
+ tokenURL: tokenURL,
+ deviceAuthURL: deviceAuthURL,
+ userInfoURL: userInfoURL,
+ jwksURL: jwksURL,
+ logoutURL: logoutURL,
+ }
+}
+
+func (kc *Keycloak) Endpoint() oauth2.Endpoint {
+ return oauth2.Endpoint{AuthURL: kc.authURL, DeviceAuthURL: kc.deviceAuthURL, TokenURL: kc.tokenURL}
+}
+
+type JWKSet struct {
+ Keys []JWK `json:"keys"`
+}
+
+type JWK struct {
+ Kty string `json:"kty"`
+ Alg string `json:"alg"`
+ Use string `json:"use"`
+ Kid string `json:"kid"`
+ N string `json:"n"`
+ E string `json:"e"`
+}
+
+func (kc *Keycloak) fetchPublicKey() (*rsa.PublicKey, error) {
+ req, err := http.NewRequest(http.MethodGet, kc.jwksURL, nil) //nolint:noctx
+ if err != nil {
+ return nil, fmt.Errorf("error creating request: %w", err)
+ }
+
+ resp, err := kc.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("error fetching JWK set: %w", err)
+ }
+ defer resp.Body.Close()
+
+ var jwkSet JWKSet
+ err = json.NewDecoder(resp.Body).Decode(&jwkSet)
+ if err != nil {
+ return nil, fmt.Errorf("error decoding JWK set: %w", err)
+ }
+
+ rsaPublicKey := jwkSet.Keys[0]
+ nBytes, err := base64.RawURLEncoding.DecodeString(rsaPublicKey.N)
+ if err != nil {
+ return nil, fmt.Errorf("error decoding N: %w", err)
+ }
+ eBytes, err := base64.RawURLEncoding.DecodeString(rsaPublicKey.E)
+ if err != nil {
+ return nil, fmt.Errorf("error decoding E: %w", err)
+ }
+
+ n := new(big.Int).SetBytes(nBytes)
+ e := int(new(big.Int).SetBytes(eBytes).Int64())
+
+ pubKey := &rsa.PublicKey{
+ N: n,
+ E: e,
+ }
+ return pubKey, nil
+}
+
+type KeycloakExternalError struct {
+ ErrorOrig string `json:"error"`
+ ErrorDescription string `json:"error_description"`
+}
+
+func (e KeycloakExternalError) Error() string {
+ return e.ErrorDescription
+}
+
+func (kc *Keycloak) revokeToken(refreshToken string) error {
+ data := url.Values{}
+ data.Set("client_id", kc.clientID)
+ data.Set("client_secret", kc.clientSecret)
+ data.Set("refresh_token", refreshToken)
+
+ req, err := http.NewRequest(http.MethodPost, kc.logoutURL, strings.NewReader(data.Encode())) //nolint:noctx
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := kc.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 && resp.StatusCode < 500 {
+ var errResp KeycloakExternalError
+ if err = json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
+ return err
+ }
+
+ return errResp
+ }
+
+ return nil
+}
diff --git a/internal/api/auth/storage.go b/internal/api/auth/storage.go
new file mode 100644
index 0000000..864b297
--- /dev/null
+++ b/internal/api/auth/storage.go
@@ -0,0 +1,37 @@
+package auth
+
+import "sync"
+
+// TODO: think about TTL for tokens
+type internalStorage struct {
+ mu sync.RWMutex
+ m map[string]TokenRepr
+}
+
+func newInternalStorage() *internalStorage {
+ return &internalStorage{
+ m: make(map[string]TokenRepr),
+ }
+}
+
+// Store sets the value for a key.
+func (cm *internalStorage) Store(key string, value TokenRepr) {
+ cm.mu.Lock()
+ cm.m[key] = value
+ cm.mu.Unlock()
+}
+
+// Get retrieves the value for a key.
+func (cm *internalStorage) Get(key string) (TokenRepr, bool) {
+ cm.mu.RLock()
+ value, ok := cm.m[key]
+ cm.mu.RUnlock()
+ return value, ok
+}
+
+// Delete removes the value for a key.
+func (cm *internalStorage) Delete(key string) {
+ cm.mu.Lock()
+ delete(cm.m, key)
+ cm.mu.Unlock()
+}
diff --git a/internal/api/errors/auth.go b/internal/api/errors/auth.go
new file mode 100644
index 0000000..b507861
--- /dev/null
+++ b/internal/api/errors/auth.go
@@ -0,0 +1,13 @@
+package errors
+
+import "errors"
+
+var ErrAuthNotAuthenticated = errors.New("not authenticated")
+var ErrAuthFailedLogout = errors.New("failed to logout")
+
+var ErrAuthMissedStateParam = errors.New("state is not present in the query parameters")
+var ErrAuthValidateBase64State = errors.New("failed to decode state")
+var ErrAuthExchangeToken = errors.New("failed to exchange token")
+var ErrAuthWrongCodeVerifier = errors.New("failed to extract code verifier")
+var ErrAuthMissingDataForCodeVerifier = errors.New("missing data for code verifier")
+var ErrAuthMissingRefreshToken = errors.New("refresh token is missing")
diff --git a/internal/api/errors/errors.go b/internal/api/errors/errors.go
index ff6e026..8b7386b 100644
--- a/internal/api/errors/errors.go
+++ b/internal/api/errors/errors.go
@@ -39,3 +39,7 @@ func RaiseBadRequestErr(c *gin.Context, err error) {
func RaiseStatusNotFoundErr(c *gin.Context, err error) {
_ = c.AbortWithError(http.StatusNotFound, ReturnError(err))
}
+
+func RaiseNotAuthorizedErr(c *gin.Context, err error) {
+ _ = c.AbortWithError(http.StatusUnauthorized, ReturnError(err))
+}
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
index 2d6101a..2d3572d 100644
--- a/internal/api/middleware.go
+++ b/internal/api/middleware.go
@@ -1,45 +1,21 @@
package api
import (
- "errors"
"fmt"
"net/http"
+ "strings"
"time"
"github.com/gin-gonic/gin"
+ "github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
+ "github.com/stackmon/otc-status-dashboard/internal/api/auth"
apiErrors "github.com/stackmon/otc-status-dashboard/internal/api/errors"
"github.com/stackmon/otc-status-dashboard/internal/db"
)
-func (a *API) ValidateComponentsMW() gin.HandlerFunc {
- return func(c *gin.Context) {
- type Components struct {
- Components []int `json:"components"`
- }
-
- var components Components
-
- if err := c.ShouldBindBodyWithJSON(&components); err != nil {
- apiErrors.RaiseBadRequestErr(c, fmt.Errorf("%w: %w", apiErrors.ErrComponentInvalidFormat, err))
- return
- }
-
- // TODO: move this list to the memory cache
- // We should check, that all components are presented in our db.
- err := a.IsPresentComponent(components.Components)
- if err != nil {
- if errors.Is(err, apiErrors.ErrComponentDSNotExist) {
- apiErrors.RaiseBadRequestErr(c, err)
- } else {
- apiErrors.RaiseInternalErr(c, err)
- }
- }
- c.Next()
- }
-}
func ValidateComponentsMW(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
logger.Info("start to validate given components")
@@ -72,20 +48,51 @@ func ValidateComponentsMW(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc {
c.Next()
}
}
+func AuthenticationMW(prov *auth.Provider, logger *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if prov.Disabled {
+ logger.Info("authentication is disabled")
+ c.Next()
+ return
+ }
-func (a *API) IsPresentComponent(components []int) error {
- dbComps, err := a.db.GetComponentsAsMap()
- if err != nil {
- return err
- }
+ logger.Info("start to process authentication request")
- for _, comp := range components {
- if _, ok := dbComps[comp]; !ok {
- return apiErrors.ErrComponentDSNotExist
+ authHeader := c.GetHeader("Authorization")
+ if authHeader == "" {
+ apiErrors.RaiseNotAuthorizedErr(c, apiErrors.ErrAuthNotAuthenticated)
+ return
}
- }
- return nil
+ rawToken := strings.TrimPrefix(authHeader, "Bearer ")
+ // Parse the JWT token and validate it using the Keycloak public key
+ token, err := jwt.Parse(rawToken, func(token *jwt.Token) (interface{}, error) {
+ // Validate the token's signing method
+ if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+ }
+
+ key, err := prov.GetPublicKey()
+ if err != nil {
+ return nil, fmt.Errorf("error while getting public key: %w", err)
+ }
+
+ return key, nil
+ })
+
+ if err != nil {
+ logger.Error("failed to parse and validate a token", zap.Error(err))
+ apiErrors.RaiseNotAuthorizedErr(c, apiErrors.ErrAuthNotAuthenticated)
+ return
+ }
+
+ if !token.Valid {
+ apiErrors.RaiseNotAuthorizedErr(c, apiErrors.ErrAuthNotAuthenticated)
+ return
+ }
+
+ c.Next()
+ }
}
func ErrorHandle() gin.HandlerFunc {
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 6930d1e..0ffe76a 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -1,44 +1,54 @@
package api
import (
+ "github.com/stackmon/otc-status-dashboard/internal/api/auth"
v1 "github.com/stackmon/otc-status-dashboard/internal/api/v1"
v2 "github.com/stackmon/otc-status-dashboard/internal/api/v2"
)
const (
- v1Group = "v1"
- v2Group = "v2"
+ authGroup = "auth"
+ v1Group = "v1"
+ v2Group = "v2"
)
func (a *API) InitRoutes() {
- v1Api := a.r.Group(v1Group)
+ authAPI := a.r.Group(authGroup)
{
- v1Api.GET("component_status", v1.GetComponentsStatusHandler(a.db, a.log))
- v1Api.POST("component_status", v1.PostComponentStatusHandler(a.db, a.log))
+ authAPI.GET("login", auth.GetLoginPageHandler(a.oa2Prov, a.log))
+ authAPI.GET("callback", auth.GetCallbackHandler(a.oa2Prov, a.log))
+ authAPI.POST("token", auth.PostTokenHandler(a.oa2Prov, a.log))
+ authAPI.PUT("logout", auth.PutLogoutHandler(a.oa2Prov, a.log))
+ }
+
+ v1API := a.r.Group(v1Group)
+ {
+ v1API.GET("component_status", v1.GetComponentsStatusHandler(a.db, a.log))
+ v1API.POST("component_status", AuthenticationMW(a.oa2Prov, a.log), v1.PostComponentStatusHandler(a.db, a.log))
- v1Api.GET("incidents", v1.GetIncidentsHandler(a.db, a.log))
+ v1API.GET("incidents", v1.GetIncidentsHandler(a.db, a.log))
}
- // setup v2 group routing
- v2Api := a.r.Group(v2Group)
+ v2API := a.r.Group(v2Group)
{
- v2Api.GET("components", v2.GetComponentsHandler(a.db, a.log))
- v2Api.POST("components", v2.PostComponentHandler(a.db, a.log))
- v2Api.GET("components/:id", v2.GetComponentHandler(a.db, a.log))
+ v2API.GET("components", v2.GetComponentsHandler(a.db, a.log))
+ v2API.POST("components", AuthenticationMW(a.oa2Prov, a.log), v2.PostComponentHandler(a.db, a.log))
+ v2API.GET("components/:id", v2.GetComponentHandler(a.db, a.log))
+
+ v2API.GET("incidents", v2.GetIncidentsHandler(a.db, a.log))
+ v2API.POST("incidents",
+ AuthenticationMW(a.oa2Prov, a.log),
+ ValidateComponentsMW(a.db, a.log),
+ v2.PostIncidentHandler(a.db, a.log),
+ )
+ v2API.GET("incidents/:id", v2.GetIncidentHandler(a.db, a.log))
+ v2API.PATCH("incidents/:id", AuthenticationMW(a.oa2Prov, a.log), v2.PatchIncidentHandler(a.db, a.log))
- v2Api.GET("incidents", v2.GetIncidentsHandler(a.db, a.log))
- v2Api.POST("incidents", ValidateComponentsMW(a.db, a.log), v2.PostIncidentHandler(a.db, a.log))
- v2Api.GET("incidents/:id", v2.GetIncidentHandler(a.db, a.log))
- v2Api.PATCH("incidents/:id", v2.PatchIncidentHandler(a.db, a.log))
+ v2API.GET("availability", v2.GetComponentsAvailabilityHandler(a.db, a.log))
- v2Api.GET("availability", v2.GetComponentsAvailabilityHandler(a.db, a.log))
//nolint:gocritic
- //v2Api.GET("rss")
- //v2Api.GET("history")
- //v2Api.GET("/separate//") - > investigate it!!!
- //
- //v2Api.GET("/login/:name")
- //v2Api.GET("/auth/:name")
- //v2Api.GET("/logout")
+ //v2API.GET("rss")
+ //v2API.GET("history")
+ //v2API.GET("/separate//") - > investigate it!!!
}
}
diff --git a/internal/app/app.go b/internal/app/app.go
index 68d93c7..e416eb8 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -36,7 +36,10 @@ func New(c *conf.Config, log *zap.Logger) (*App, error) {
return nil, err
}
- apiNew := api.New(c, log, dbNew)
+ apiNew, err := api.New(c, log, dbNew)
+ if err != nil {
+ return nil, err
+ }
s := &http.Server{
Addr: fmt.Sprintf(":%s", c.Port),
diff --git a/internal/conf/conf.go b/internal/conf/conf.go
index 97b7afc..2ff4a61 100644
--- a/internal/conf/conf.go
+++ b/internal/conf/conf.go
@@ -1,17 +1,25 @@
package conf
import (
+ "errors"
"fmt"
+ "reflect"
"strconv"
+ "strings"
"github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
)
const osPref = "SD"
-
const DevelopMode = "devel"
+const (
+ DefaultWebURL = "http://localhost:9000"
+ DefaultHostname = "localhost"
+ DefaultPort = "8000"
+)
+
type Config struct {
// DB connection uri
// format is `postgresql://user:pass@host:port/db_name`
@@ -19,10 +27,27 @@ type Config struct {
// Cache connection uri
// It can be redis format or internal
Cache string `envconfig:"CACHE"`
+ // Keycloak settings
+ Keycloak *Keycloak `envconfig:"KEYCLOAK"`
// Log level for verbosity
LogLevel string `envconfig:"LOG_LEVEL"`
// App port
- Port string `envconfig:"PORT" default:"8000"`
+ Port string `envconfig:"PORT"`
+ // Hostname for the app, mostly used to generate a callback URL for keycloak
+ Hostname string `envconfig:"HOSTNAME"`
+ // Enable SSL for the app
+ SSLDisabled bool `envconfig:"SSL_DISABLED"`
+ // Web URL for the app
+ WebURL string `envconfig:"WEB_URL"`
+ // Disable authentication for any reasons it doesn't work with hostname like "*prod*"
+ AuthenticationDisabled bool `envconfig:"AUTHENTICATION_DISABLED"`
+}
+
+type Keycloak struct {
+ URL string `envconfig:"URL"`
+ Realm string `envconfig:"REALM"`
+ ClientID string `envconfig:"CLIENT_ID"`
+ ClientSecret string `envconfig:"CLIENT_SECRET"`
}
func (c *Config) Validate() error {
@@ -34,10 +59,25 @@ func (c *Config) Validate() error {
return fmt.Errorf("wrong port for http server")
}
+ return nil
+}
+
+func (c *Config) FillDefaults() {
if c.LogLevel == "" {
c.LogLevel = DevelopMode
}
- return nil
+
+ if c.Port == "" {
+ c.Port = DefaultPort
+ }
+
+ if c.Hostname == "" {
+ c.Hostname = DefaultHostname
+ }
+
+ if c.WebURL == "" {
+ c.WebURL = DefaultWebURL
+ }
}
// LoadConf loads configuration from .env file and environment.
@@ -52,7 +92,11 @@ func LoadConf() (*Config, error) {
return nil, err
}
- mergeConfigs(envMap, &c)
+ if err = mergeConfigs(envMap, &c, osPref); err != nil {
+ return nil, err
+ }
+
+ c.FillDefaults()
if err = c.Validate(); err != nil {
return nil, err
@@ -61,33 +105,62 @@ func LoadConf() (*Config, error) {
return &c, nil
}
-func mergeConfigs(env map[string]string, c *Config) {
+var ErrInvalidDataMerge = errors.New("could not merge config, the obj must be a point to a struct")
+
+const envConfigTag = "envconfig"
+
+// mergeConfigs allow to merge config params from env variables and .env file.
+// It checks the Config struct and if the value is missing, it set up the value from .env file.
+func mergeConfigs(env map[string]string, obj any, prefix string) error { //nolint:gocognit
if env == nil {
- return
+ return nil
}
- // TODO: use reflect to automate it
- if c.DB == "" {
- v, ok := env["SD_DB"]
- if ok {
- c.DB = v
- }
+
+ v := reflect.ValueOf(obj)
+
+ if v.Kind() != reflect.Ptr {
+ return ErrInvalidDataMerge
}
- if c.Cache == "" {
- v, ok := env["SD_CACHE"]
- if ok {
- c.Cache = v
- }
+
+ v = v.Elem()
+ if v.Kind() != reflect.Struct {
+ return ErrInvalidDataMerge
}
- if c.LogLevel == "" {
- v, ok := env["SD_LOG_LEVEL"]
- if ok {
- c.LogLevel = v
+
+ t := v.Type()
+
+ // Iterate through the fields
+ for i := 0; i < v.NumField(); i++ { //nolint:intrange
+ field := t.Field(i)
+ value := v.Field(i)
+
+ if value.Kind() == reflect.Ptr && value.Elem().Kind() == reflect.Struct {
+ envValueTag := field.Tag.Get(envConfigTag)
+ confPrefix := fmt.Sprintf("%s_%s", prefix, envValueTag)
+ err := mergeConfigs(env, value.Interface(), confPrefix)
+ if err != nil {
+ return err
+ }
+
+ continue
}
- }
- if c.Port == "" {
- v, ok := env["SD_PORT"]
- if ok {
- c.Port = v
+
+ if value.IsZero() && value.IsValid() && value.CanSet() {
+ envValueTag := field.Tag.Get(envConfigTag)
+ mapKey := strings.ToUpper(fmt.Sprintf("%s_%s", prefix, envValueTag))
+
+ switch value.Kind() { //nolint:exhaustive
+ case reflect.String:
+ value.SetString(env[mapKey])
+ case reflect.Bool:
+ if env[mapKey] == "true" {
+ value.SetBool(true)
+ }
+ default:
+ return fmt.Errorf("unsupported type for config field %s", field.Name)
+ }
}
}
+
+ return nil
}
diff --git a/openapi.yaml b/openapi.yaml
index ed458e0..71d5cbc 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -5,6 +5,8 @@ info:
servers:
- url: http://localhost:8000
tags:
+ - name: authentication
+ description: Authentication operations
- name: incidents
description: Incident management
- name: components
@@ -12,6 +14,104 @@ tags:
- name: v1
description: Deprecated API schema for backward compatibility
paths:
+ /auth/login:
+ get:
+ summary: Redirect to the keycloak auth realm.
+ tags:
+ - authentication
+ parameters:
+ - name: state
+ in: query
+ description: The state for oauth2 request. It's a base64 encoded JSON object. The object should contain the `code_challenge` and `callback_url`.
+ required: true
+ schema:
+ type: string
+ example: eyJjb2RlX2NoYWxsZW5nZSI6IjY0Y2MwYWIxYTg4ZWZlYWNkNjRmYTc5ZWNlMzRlZGUwNDRjZDZkMWMzMmMyYTFjMjc5MWU1YmEyMDYzYzFiZWEiLCJjYWxsYmFja191cmwiOiJodHRwOi8vbG9jYWxob3N0OjUxNzMvY2FsbGJhY2sifQ
+ responses:
+ '303':
+ description: The redirect to a keycloak auth page.
+ '400':
+ description: The request is invalid.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BadRequestGeneralError'
+ /auth/callback:
+ get:
+ summary: The callback URL for the keycloak auth realm. It exchanges the code from keycloak and store tokens for a user. It redirects to the frontend callback URL from the state parameter.
+ tags:
+ - authentication
+ parameters:
+ - name: code
+ in: query
+ description: The code from the keycloak auth realm.
+ required: true
+ schema:
+ type: string
+ example: 7b3b3b3b-3b3b-3b3b-3b3b-3b3b3b3b3b3b
+ - name: state
+ in: query
+ description: The state from the authentication request.
+ required: true
+ schema:
+ type: string
+ example: eyJjb2RlX2NoYWxsZW5nZSI6IjY0Y2MwYWIxYTg4ZWZlYWNkNjRmYTc5ZWNlMzRlZGUwNDRjZDZkMWMzMmMyYTFjMjc5MWU1YmEyMDYzYzFiZWEiLCJjYWxsYmFja191cmwiOiJodHRwOi8vbG9jYWxob3N0OjUxNzMvY2FsbGJhY2sifQ
+ responses:
+ '303':
+ description: The redirect to a frontend callback url.
+ '400':
+ description: Return this code with a redirect to a frontend url.
+ headers:
+ Set-Cookie:
+ description: Error cookie set by the server.
+ schema:
+ type: string
+ example: error=some_error_message; Path=/; HttpOnly
+ /auth/token:
+ post:
+ summary: Retrieve a token for an authorised client.
+ tags:
+ - authentication
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TokenPostRequest'
+ required: true
+ responses:
+ '200':
+ description: Return access and refresh tokens for a user.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TokenPostResponse'
+ '400':
+ description: The request is invalid.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BadRequestGeneralError'
+ /auth/logout:
+ put:
+ summary: Logout user session.
+ tags:
+ - authentication
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TokenPutRequest'
+ required: true
+ responses:
+ '204':
+ description: The request is successful.
+ '400':
+ description: The request is invalid.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BadRequestGeneralError'
+
/v2/components:
get:
summary: Get all components.
@@ -190,6 +290,7 @@ paths:
description: Invalid ID supplied
'404':
description: Incident not found.
+
/v1/component_status:
get:
summary: Get all components.
@@ -220,7 +321,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/IncidentV1'
-
/v1/incidents:
get:
summary: Get all incidents.
@@ -236,6 +336,28 @@ paths:
components:
schemas:
+ TokenPostRequest:
+ type: object
+ required:
+ - code_verifier
+ properties:
+ code_verifier:
+ type: string
+ example: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+ TokenPostResponse:
+ type: object
+ properties:
+ access_token:
+ type: string
+ refresh_token:
+ type: string
+ TokenPutRequest:
+ type: object
+ required:
+ - refresh_token
+ properties:
+ refresh_token:
+ type: string
Component:
type: object
required:
@@ -572,3 +694,9 @@ components:
errMsg:
type: string
example: internal server error
+ BadRequestGeneralError:
+ type: object
+ properties:
+ errMsg:
+ type: string
+ example: "any error message"
diff --git a/tests/auth_test.go b/tests/auth_test.go
new file mode 100644
index 0000000..10c2503
--- /dev/null
+++ b/tests/auth_test.go
@@ -0,0 +1,53 @@
+package tests
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+const (
+ authURL = "/auth"
+)
+
+func TestAuth(t *testing.T) {
+ t.Log("start to test for /auth/login")
+
+ r, _, oa2Prov := initTests(t)
+
+ codeVerifier := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+ callbackURL := fmt.Sprintf("%s/callback", oa2Prov.WebURL)
+
+ state := prepareState(codeVerifier, callbackURL)
+
+ // the hashed state is:
+ // eyJjb2RlX2NoYWxsZW5nZSI6IjY0Y2MwYWIxYTg4ZWZlYWNkNjRmYTc5ZWNlMzRlZGUwNDRjZDZkMWMzMmMyYTFjMjc5MWU1YmEyMDYzYzFiZWEiLCJjYWxsYmFja191cmwiOiJodHRwOi8vbG9jYWxob3N0OjUxNzMvY2FsbGJhY2sifQ
+ url := fmt.Sprintf("%s/login?state=%s", authURL, state)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest(http.MethodGet, url, nil)
+
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, 303, w.Code)
+ // TODO: check state param
+}
+
+func prepareState(codeVerifier, callbackURL string) string {
+ codeChallenge := sha256sum(codeVerifier)
+ state := fmt.Sprintf("{\"code_challenge\":\"%s\",\"callback_url\":\"%s\"}", codeChallenge, callbackURL)
+
+ return base64.RawURLEncoding.EncodeToString([]byte(state))
+}
+
+func sha256sum(s string) string {
+ h := sha256.New()
+ h.Write([]byte(s))
+ return hex.EncodeToString(h.Sum(nil))
+}
diff --git a/tests/main_test.go b/tests/main_test.go
index 4fc28ed..386be17 100644
--- a/tests/main_test.go
+++ b/tests/main_test.go
@@ -17,6 +17,7 @@ import (
"go.uber.org/zap"
"github.com/stackmon/otc-status-dashboard/internal/api"
+ "github.com/stackmon/otc-status-dashboard/internal/api/auth"
"github.com/stackmon/otc-status-dashboard/internal/api/errors"
v1 "github.com/stackmon/otc-status-dashboard/internal/api/v1"
v2 "github.com/stackmon/otc-status-dashboard/internal/api/v2"
@@ -66,7 +67,7 @@ func TestMain(m *testing.M) {
m.Run()
}
-func initTests(t *testing.T) (*gin.Engine, *db.DB) {
+func initTests(t *testing.T) (*gin.Engine, *db.DB, *auth.Provider) {
t.Helper()
t.Log("init structs")
@@ -83,36 +84,56 @@ func initTests(t *testing.T) (*gin.Engine, *db.DB) {
r.Use(api.ErrorHandle())
logger, _ := zap.NewDevelopment()
+
+ cfg, err := conf.LoadConf()
+ require.NoError(t, err)
+
+ oa2Prov, err := auth.NewProvider(cfg.Keycloak.URL, cfg.Keycloak.Realm, cfg.Keycloak.ClientID, cfg.Keycloak.ClientSecret, cfg.Hostname, cfg.WebURL)
+ require.NoError(t, err)
+
+ initRoutesAuth(t, r, oa2Prov, logger)
initRoutesV1(t, r, d, logger)
initRoutesV2(t, r, d, logger)
- return r, d
+ return r, d, oa2Prov
+}
+
+func initRoutesAuth(t *testing.T, c *gin.Engine, oa2Prov *auth.Provider, logger *zap.Logger) {
+ t.Helper()
+ t.Log("init routes for auth")
+
+ authAPI := c.Group("auth")
+
+ authAPI.GET("login", auth.GetLoginPageHandler(oa2Prov, logger))
+ authAPI.GET("callback", auth.GetCallbackHandler(oa2Prov, logger))
+ authAPI.POST("token", auth.PostTokenHandler(oa2Prov, logger))
+ authAPI.POST("logout", auth.PostTokenHandler(oa2Prov, logger))
}
-func initRoutesV1(t *testing.T, c *gin.Engine, dbInst *db.DB, log *zap.Logger) {
+func initRoutesV1(t *testing.T, c *gin.Engine, dbInst *db.DB, logger *zap.Logger) {
t.Helper()
t.Log("init routes for V1")
v1Api := c.Group("v1")
- v1Api.GET("component_status", v1.GetComponentsStatusHandler(dbInst, log))
- v1Api.POST("component_status", v1.PostComponentStatusHandler(dbInst, log))
+ v1Api.GET("component_status", v1.GetComponentsStatusHandler(dbInst, logger))
+ v1Api.POST("component_status", v1.PostComponentStatusHandler(dbInst, logger))
- v1Api.GET("incidents", v1.GetIncidentsHandler(dbInst, log))
+ v1Api.GET("incidents", v1.GetIncidentsHandler(dbInst, logger))
}
-func initRoutesV2(t *testing.T, c *gin.Engine, dbInst *db.DB, log *zap.Logger) {
+func initRoutesV2(t *testing.T, c *gin.Engine, dbInst *db.DB, logger *zap.Logger) {
t.Helper()
t.Log("init routes for V2")
v2Api := c.Group("v2")
- v2Api.GET("components", v2.GetComponentsHandler(dbInst, log))
- v2Api.POST("components", v2.PostComponentHandler(dbInst, log))
- v2Api.GET("components/:id", v2.GetComponentHandler(dbInst, log))
+ v2Api.GET("components", v2.GetComponentsHandler(dbInst, logger))
+ v2Api.POST("components", v2.PostComponentHandler(dbInst, logger))
+ v2Api.GET("components/:id", v2.GetComponentHandler(dbInst, logger))
- v2Api.GET("incidents", v2.GetIncidentsHandler(dbInst, log))
- v2Api.POST("incidents", api.ValidateComponentsMW(dbInst, log), v2.PostIncidentHandler(dbInst, log))
- v2Api.GET("incidents/:id", v2.GetIncidentHandler(dbInst, log))
- v2Api.PATCH("incidents/:id", v2.PatchIncidentHandler(dbInst, log))
+ v2Api.GET("incidents", v2.GetIncidentsHandler(dbInst, logger))
+ v2Api.POST("incidents", api.ValidateComponentsMW(dbInst, logger), v2.PostIncidentHandler(dbInst, logger))
+ v2Api.GET("incidents/:id", v2.GetIncidentHandler(dbInst, logger))
+ v2Api.PATCH("incidents/:id", v2.PatchIncidentHandler(dbInst, logger))
}
diff --git a/tests/v1_test.go b/tests/v1_test.go
index 3ddb532..da03e22 100644
--- a/tests/v1_test.go
+++ b/tests/v1_test.go
@@ -19,7 +19,7 @@ import (
func TestV1GetIncidentsHandler(t *testing.T) {
t.Log("start to test GET /v1/incidents")
- r, _ := initTests(t)
+ r, _, _ := initTests(t)
var response = `[{"id":1,"text":"Closed incident without any update","impact":1,"start_date":"2024-10-24 10:12","end_date":"2024-10-24 11:12","updates":[{"status":"resolved","text":"close incident","timestamp":"2024-10-24 11:12"}]}]`
@@ -34,7 +34,7 @@ func TestV1GetIncidentsHandler(t *testing.T) {
func TestV1GetComponentsStatusHandler(t *testing.T) {
t.Log("start to test GET /v1/component_status")
- r, _ := initTests(t)
+ r, _, _ := initTests(t)
var response = `[{"id":1,"attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Container"},{"name":"type","value":"cce"}],"name":"Cloud Container Engine","incidents":[{"id":1,"text":"Closed incident without any update","impact":1,"start_date":"2024-10-24 10:12","end_date":"2024-10-24 11:12","updates":[{"status":"resolved","text":"close incident","timestamp":"2024-10-24 11:12"}]}]},{"id":2,"attributes":[{"name":"region","value":"EU-NL"},{"name":"category","value":"Container"},{"name":"type","value":"cce"}],"name":"Cloud Container Engine","incidents":[]},{"id":3,"attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Compute"},{"name":"type","value":"ecs"}],"name":"Elastic Cloud Server","incidents":[]},{"id":4,"attributes":[{"name":"region","value":"EU-NL"},{"name":"category","value":"Compute"},{"name":"type","value":"ecs"}],"name":"Elastic Cloud Server","incidents":[]},{"id":5,"attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Database"},{"name":"type","value":"dcs"}],"name":"Distributed Cache Service","incidents":[]},{"id":6,"attributes":[{"name":"region","value":"EU-NL"},{"name":"category","value":"Database"},{"name":"type","value":"dcs"}],"name":"Distributed Cache Service","incidents":[]}]`
@@ -49,7 +49,7 @@ func TestV1GetComponentsStatusHandler(t *testing.T) {
func TestV1PostComponentsStatusHandlerNegative(t *testing.T) {
t.Log("start to test incident creation and check json data for /v1/component_status")
- r, _ := initTests(t)
+ r, _, _ := initTests(t)
type testCase struct {
ExpectedCode int
@@ -89,7 +89,7 @@ func TestV1PostComponentsStatusHandlerNegative(t *testing.T) {
func TestV1PostComponentsStatusHandler(t *testing.T) {
t.Log("start to test incident creation, modification by /v1/component_status")
- r, dbIns := initTests(t)
+ r, dbIns, _ := initTests(t)
t.Log("create an incident")
diff --git a/tests/v2_test.go b/tests/v2_test.go
index 90cb0f9..b240ca4 100644
--- a/tests/v2_test.go
+++ b/tests/v2_test.go
@@ -23,7 +23,7 @@ const (
func TestV2GetIncidentsHandler(t *testing.T) {
t.Log("start to test GET /v2/incidents")
- r, _ := initTests(t)
+ r, _, _ := initTests(t)
incidentStr := `{"id":1,"title":"Closed incident without any update","impact":1,"components":[1],"start_date":"2024-10-24T10:12:42Z","end_date":"2024-10-24T11:12:42Z","system":false,"updates":[{"status":"resolved","text":"close incident","timestamp":"2024-10-24T11:12:42.559346Z"}]}`
@@ -51,7 +51,7 @@ func TestV2GetIncidentsHandler(t *testing.T) {
func TestV2GetComponentsHandler(t *testing.T) {
t.Log("start to test GET /v2/components")
- r, _ := initTests(t)
+ r, _, _ := initTests(t)
var response = `[{"id":1,"name":"Cloud Container Engine","attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Container"},{"name":"type","value":"cce"}]},{"id":2,"name":"Cloud Container Engine","attributes":[{"name":"region","value":"EU-NL"},{"name":"category","value":"Container"},{"name":"type","value":"cce"}]},{"id":3,"name":"Elastic Cloud Server","attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Compute"},{"name":"type","value":"ecs"}]},{"id":4,"name":"Elastic Cloud Server","attributes":[{"name":"region","value":"EU-NL"},{"name":"category","value":"Compute"},{"name":"type","value":"ecs"}]},{"id":5,"name":"Distributed Cache Service","attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Database"},{"name":"type","value":"dcs"}]},{"id":6,"name":"Distributed Cache Service","attributes":[{"name":"region","value":"EU-NL"},{"name":"category","value":"Database"},{"name":"type","value":"dcs"}]}]`
@@ -66,7 +66,7 @@ func TestV2GetComponentsHandler(t *testing.T) {
func TestV2PostIncidentsHandlerNegative(t *testing.T) {
t.Log("start to test incident creation and check json data for /v2/incidents")
- r, _ := initTests(t)
+ r, _, _ := initTests(t)
type testCase struct {
ExpectedCode int
@@ -152,7 +152,7 @@ func TestV2PostIncidentsHandlerNegative(t *testing.T) {
func TestV2PostIncidentsHandler(t *testing.T) {
t.Log("start to test incident creation for /v2/incidents")
- r, _ := initTests(t)
+ r, _, _ := initTests(t)
t.Log("create an incident")
@@ -255,7 +255,7 @@ func TestV2PostIncidentsHandler(t *testing.T) {
func TestV2PatchIncidentHandlerNegative(t *testing.T) {
t.Log("start to test negative cases for incident patching and check json data for /v2/incidents/42")
- r, _ := initTests(t)
+ r, _, _ := initTests(t)
components := []int{1}
impact := 1
@@ -348,7 +348,7 @@ func TestV2PatchIncidentHandlerNegative(t *testing.T) {
func TestV2PatchIncidentHandler(t *testing.T) {
t.Log("start to test incident patching")
- r, _ := initTests(t)
+ r, _, _ := initTests(t)
components := []int{1}
impact := 1