From e6b85b641cfa924b1b163f46b1a84d4572d8de20 Mon Sep 17 00:00:00 2001 From: Manuel Sabban Date: Tue, 6 May 2025 16:23:56 +0200 Subject: [PATCH] feat(apic): add ApicAuth client and token re-authentication logic (#3522) --- pkg/apiserver/apic.go | 99 ++++++++++++++++++++++++++---- pkg/database/ent/migrate/schema.go | 2 +- pkg/database/ent/schema/config.go | 6 +- 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/pkg/apiserver/apic.go b/pkg/apiserver/apic.go index 07d2f9fcb..8e92dc674 100644 --- a/pkg/apiserver/apic.go +++ b/pkg/apiserver/apic.go @@ -17,6 +17,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/go-openapi/strfmt" + "github.com/golang-jwt/jwt/v4" log "github.com/sirupsen/logrus" "gopkg.in/tomb.v2" @@ -213,8 +214,6 @@ func NewAPIC(ctx context.Context, config *csconfig.OnlineApiClientCfg, dbClient shareSignals: *config.Sharing, } - password := strfmt.Password(config.Credentials.Password) - apiURL, err := url.Parse(config.Credentials.URL) if err != nil { return nil, fmt.Errorf("while parsing '%s': %w", config.Credentials.URL, err) @@ -232,7 +231,7 @@ func NewAPIC(ctx context.Context, config *csconfig.OnlineApiClientCfg, dbClient ret.apiClient, err = apiclient.NewClient(&apiclient.Config{ MachineID: config.Credentials.Login, - Password: password, + Password: strfmt.Password(config.Credentials.Password), URL: apiURL, PapiURL: papiURL, VersionPrefix: "v3", @@ -243,29 +242,103 @@ func NewAPIC(ctx context.Context, config *csconfig.OnlineApiClientCfg, dbClient return nil, fmt.Errorf("while creating api client: %w", err) } - // The watcher will be authenticated by the RoundTripper the first time it will call CAPI - // Explicit authentication will provoke a useless supplementary call to CAPI - scenarios, err := ret.FetchScenariosListFromDB(ctx) + err = ret.Authenticate(ctx, config) + return ret, err +} + +// loadAPICToken attempts to retrieve and validate a JWT token from the local database. +// It returns the token string, its expiration time, and a boolean indicating whether the token is valid. +// +// A token is considered valid if: +// - it exists in the database, +// - it is a properly formatted JWT with an "exp" claim, +// - it is not expired or near expiry. +func loadAPICToken(ctx context.Context, db *database.Client) (string, time.Time, bool) { + token, err := db.GetConfigItem(ctx, "apic_token") if err != nil { - return ret, fmt.Errorf("get scenario in db: %w", err) + log.Debugf("error fetching token from DB: %s", err) + return "", time.Time{}, false } - authResp, _, err := ret.apiClient.Auth.AuthenticateWatcher(ctx, models.WatcherAuthRequest{ + if token == nil { + log.Debug("no token found in DB") + return "", time.Time{}, false + } + + parser := new(jwt.Parser) + tok, _, err := parser.ParseUnverified(*token, jwt.MapClaims{}) + if err != nil { + log.Debugf("error parsing token: %s", err) + return "", time.Time{}, false + } + + claims, ok := tok.Claims.(jwt.MapClaims) + if !ok { + log.Debugf("error parsing token claims: %s", err) + return "", time.Time{}, false + } + + expFloat, ok := claims["exp"].(float64) + if !ok { + log.Debug("token missing 'exp' claim") + return "", time.Time{}, false + } + + exp := time.Unix(int64(expFloat), 0) + if time.Now().UTC().After(exp.Add(-1*time.Minute)) { + log.Debug("auth token expired") + return "", time.Time{}, false + } + + return *token, exp, true +} + +// saveAPICToken stores the given JWT token in the local database under the "apic_token" config item. +func saveAPICToken(ctx context.Context, db *database.Client, token string) error { + if err := db.SetConfigItem(ctx, "apic_token", token); err != nil { + return fmt.Errorf("saving token to db: %w", err) + } + + return nil +} + +// Authenticate ensures the API client is authorized to communicate with the CAPI. +// It attempts to reuse a previously saved JWT token from the database, falling back to +// an authentication request if the token is missing, invalid, or expired. +// +// If a new token is obtained, it is saved back to the database for caching. +func (a *apic) Authenticate(ctx context.Context, config *csconfig.OnlineApiClientCfg) error { + if token, exp, valid := loadAPICToken(ctx, a.dbClient); valid { + log.Debug("using valid token from DB") + a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Token = token + a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Expiration = exp + } + + log.Debug("No token found, authenticating") + + scenarios, err := a.FetchScenariosListFromDB(ctx) + if err != nil { + return fmt.Errorf("get scenario in db: %w", err) + } + + password := strfmt.Password(config.Credentials.Password) + + authResp, _, err := a.apiClient.Auth.AuthenticateWatcher(ctx, models.WatcherAuthRequest{ MachineID: &config.Credentials.Login, Password: &password, Scenarios: scenarios, }) if err != nil { - return ret, fmt.Errorf("authenticate watcher (%s): %w", config.Credentials.Login, err) + return fmt.Errorf("authenticate watcher (%s): %w", config.Credentials.Login, err) } - if err = ret.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Expiration.UnmarshalText([]byte(authResp.Expire)); err != nil { - return ret, fmt.Errorf("unable to parse jwt expiration: %w", err) + if err = a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Expiration.UnmarshalText([]byte(authResp.Expire)); err != nil { + return fmt.Errorf("unable to parse jwt expiration: %w", err) } - ret.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Token = authResp.Token + a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Token = authResp.Token - return ret, err + return saveAPICToken(ctx, a.dbClient, authResp.Token) } // keep track of all alerts in cache and push it to CAPI every PushInterval. diff --git a/pkg/database/ent/migrate/schema.go b/pkg/database/ent/migrate/schema.go index 932c27dd7..571c04af8 100644 --- a/pkg/database/ent/migrate/schema.go +++ b/pkg/database/ent/migrate/schema.go @@ -148,7 +148,7 @@ var ( {Name: "created_at", Type: field.TypeTime}, {Name: "updated_at", Type: field.TypeTime}, {Name: "name", Type: field.TypeString, Unique: true}, - {Name: "value", Type: field.TypeString}, + {Name: "value", Type: field.TypeString, SchemaType: map[string]string{"mysql": "longtext", "postgres": "text"}}, } // ConfigItemsTable holds the schema information for the "config_items" table. ConfigItemsTable = &schema.Table{ diff --git a/pkg/database/ent/schema/config.go b/pkg/database/ent/schema/config.go index d526db25a..2f12f4491 100644 --- a/pkg/database/ent/schema/config.go +++ b/pkg/database/ent/schema/config.go @@ -2,6 +2,7 @@ package schema import ( "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/schema/field" "github.com/crowdsecurity/crowdsec/pkg/types" @@ -22,7 +23,10 @@ func (ConfigItem) Fields() []ent.Field { Default(types.UtcNow). UpdateDefault(types.UtcNow).StructTag(`json:"updated_at"`), field.String("name").Unique().StructTag(`json:"name"`).Immutable(), - field.String("value").StructTag(`json:"value"`), // a json object + field.String("value").SchemaType(map[string]string{ + dialect.MySQL: "longtext", + dialect.Postgres: "text", + }).StructTag(`json:"value"`), // a json object } }