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