feat(apic): add ApicAuth client and token re-authentication logic (#3522)

This commit is contained in:
Manuel Sabban 2025-05-06 16:23:56 +02:00 committed by GitHub
parent 3b9130469c
commit e6b85b641c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 92 additions and 15 deletions

View file

@ -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)
if err != nil {
return ret, fmt.Errorf("get scenario in db: %w", err)
err = ret.Authenticate(ctx, config)
return ret, err
}
authResp, _, err := ret.apiClient.Auth.AuthenticateWatcher(ctx, models.WatcherAuthRequest{
// 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 {
log.Debugf("error fetching token from DB: %s", err)
return "", time.Time{}, false
}
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.

View file

@ -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{

View file

@ -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
}
}