mirror of
https://github.com/crowdsecurity/crowdsec.git
synced 2025-05-11 04:15:54 +02:00
feat(apic): add ApicAuth client and token re-authentication logic (#3522)
This commit is contained in:
parent
3b9130469c
commit
e6b85b641c
3 changed files with 92 additions and 15 deletions
|
@ -17,6 +17,7 @@ import (
|
||||||
|
|
||||||
"github.com/davecgh/go-spew/spew"
|
"github.com/davecgh/go-spew/spew"
|
||||||
"github.com/go-openapi/strfmt"
|
"github.com/go-openapi/strfmt"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"gopkg.in/tomb.v2"
|
"gopkg.in/tomb.v2"
|
||||||
|
|
||||||
|
@ -213,8 +214,6 @@ func NewAPIC(ctx context.Context, config *csconfig.OnlineApiClientCfg, dbClient
|
||||||
shareSignals: *config.Sharing,
|
shareSignals: *config.Sharing,
|
||||||
}
|
}
|
||||||
|
|
||||||
password := strfmt.Password(config.Credentials.Password)
|
|
||||||
|
|
||||||
apiURL, err := url.Parse(config.Credentials.URL)
|
apiURL, err := url.Parse(config.Credentials.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("while parsing '%s': %w", config.Credentials.URL, err)
|
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{
|
ret.apiClient, err = apiclient.NewClient(&apiclient.Config{
|
||||||
MachineID: config.Credentials.Login,
|
MachineID: config.Credentials.Login,
|
||||||
Password: password,
|
Password: strfmt.Password(config.Credentials.Password),
|
||||||
URL: apiURL,
|
URL: apiURL,
|
||||||
PapiURL: papiURL,
|
PapiURL: papiURL,
|
||||||
VersionPrefix: "v3",
|
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)
|
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
|
err = ret.Authenticate(ctx, config)
|
||||||
// Explicit authentication will provoke a useless supplementary call to CAPI
|
return ret, err
|
||||||
scenarios, err := ret.FetchScenariosListFromDB(ctx)
|
}
|
||||||
|
|
||||||
|
// 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 {
|
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,
|
MachineID: &config.Credentials.Login,
|
||||||
Password: &password,
|
Password: &password,
|
||||||
Scenarios: scenarios,
|
Scenarios: scenarios,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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 {
|
if err = a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Expiration.UnmarshalText([]byte(authResp.Expire)); err != nil {
|
||||||
return ret, fmt.Errorf("unable to parse jwt expiration: %w", err)
|
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.
|
// keep track of all alerts in cache and push it to CAPI every PushInterval.
|
||||||
|
|
|
@ -148,7 +148,7 @@ var (
|
||||||
{Name: "created_at", Type: field.TypeTime},
|
{Name: "created_at", Type: field.TypeTime},
|
||||||
{Name: "updated_at", Type: field.TypeTime},
|
{Name: "updated_at", Type: field.TypeTime},
|
||||||
{Name: "name", Type: field.TypeString, Unique: true},
|
{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 holds the schema information for the "config_items" table.
|
||||||
ConfigItemsTable = &schema.Table{
|
ConfigItemsTable = &schema.Table{
|
||||||
|
|
|
@ -2,6 +2,7 @@ package schema
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"entgo.io/ent"
|
"entgo.io/ent"
|
||||||
|
"entgo.io/ent/dialect"
|
||||||
"entgo.io/ent/schema/field"
|
"entgo.io/ent/schema/field"
|
||||||
|
|
||||||
"github.com/crowdsecurity/crowdsec/pkg/types"
|
"github.com/crowdsecurity/crowdsec/pkg/types"
|
||||||
|
@ -22,7 +23,10 @@ func (ConfigItem) Fields() []ent.Field {
|
||||||
Default(types.UtcNow).
|
Default(types.UtcNow).
|
||||||
UpdateDefault(types.UtcNow).StructTag(`json:"updated_at"`),
|
UpdateDefault(types.UtcNow).StructTag(`json:"updated_at"`),
|
||||||
field.String("name").Unique().StructTag(`json:"name"`).Immutable(),
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue