Skip to content

Commit e6b85b6

Browse files
authored
feat(apic): add ApicAuth client and token re-authentication logic (#3522)
1 parent 3b91304 commit e6b85b6

File tree

3 files changed

+92
-15
lines changed

3 files changed

+92
-15
lines changed

pkg/apiserver/apic.go

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
"github.com/davecgh/go-spew/spew"
1919
"github.com/go-openapi/strfmt"
20+
"github.com/golang-jwt/jwt/v4"
2021
log "github.com/sirupsen/logrus"
2122
"gopkg.in/tomb.v2"
2223

@@ -213,8 +214,6 @@ func NewAPIC(ctx context.Context, config *csconfig.OnlineApiClientCfg, dbClient
213214
shareSignals: *config.Sharing,
214215
}
215216

216-
password := strfmt.Password(config.Credentials.Password)
217-
218217
apiURL, err := url.Parse(config.Credentials.URL)
219218
if err != nil {
220219
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
232231

233232
ret.apiClient, err = apiclient.NewClient(&apiclient.Config{
234233
MachineID: config.Credentials.Login,
235-
Password: password,
234+
Password: strfmt.Password(config.Credentials.Password),
236235
URL: apiURL,
237236
PapiURL: papiURL,
238237
VersionPrefix: "v3",
@@ -243,29 +242,103 @@ func NewAPIC(ctx context.Context, config *csconfig.OnlineApiClientCfg, dbClient
243242
return nil, fmt.Errorf("while creating api client: %w", err)
244243
}
245244

246-
// The watcher will be authenticated by the RoundTripper the first time it will call CAPI
247-
// Explicit authentication will provoke a useless supplementary call to CAPI
248-
scenarios, err := ret.FetchScenariosListFromDB(ctx)
245+
err = ret.Authenticate(ctx, config)
246+
return ret, err
247+
}
248+
249+
// loadAPICToken attempts to retrieve and validate a JWT token from the local database.
250+
// It returns the token string, its expiration time, and a boolean indicating whether the token is valid.
251+
//
252+
// A token is considered valid if:
253+
// - it exists in the database,
254+
// - it is a properly formatted JWT with an "exp" claim,
255+
// - it is not expired or near expiry.
256+
func loadAPICToken(ctx context.Context, db *database.Client) (string, time.Time, bool) {
257+
token, err := db.GetConfigItem(ctx, "apic_token")
258+
if err != nil {
259+
log.Debugf("error fetching token from DB: %s", err)
260+
return "", time.Time{}, false
261+
}
262+
263+
if token == nil {
264+
log.Debug("no token found in DB")
265+
return "", time.Time{}, false
266+
}
267+
268+
parser := new(jwt.Parser)
269+
tok, _, err := parser.ParseUnverified(*token, jwt.MapClaims{})
270+
if err != nil {
271+
log.Debugf("error parsing token: %s", err)
272+
return "", time.Time{}, false
273+
}
274+
275+
claims, ok := tok.Claims.(jwt.MapClaims)
276+
if !ok {
277+
log.Debugf("error parsing token claims: %s", err)
278+
return "", time.Time{}, false
279+
}
280+
281+
expFloat, ok := claims["exp"].(float64)
282+
if !ok {
283+
log.Debug("token missing 'exp' claim")
284+
return "", time.Time{}, false
285+
}
286+
287+
exp := time.Unix(int64(expFloat), 0)
288+
if time.Now().UTC().After(exp.Add(-1*time.Minute)) {
289+
log.Debug("auth token expired")
290+
return "", time.Time{}, false
291+
}
292+
293+
return *token, exp, true
294+
}
295+
296+
// saveAPICToken stores the given JWT token in the local database under the "apic_token" config item.
297+
func saveAPICToken(ctx context.Context, db *database.Client, token string) error {
298+
if err := db.SetConfigItem(ctx, "apic_token", token); err != nil {
299+
return fmt.Errorf("saving token to db: %w", err)
300+
}
301+
302+
return nil
303+
}
304+
305+
// Authenticate ensures the API client is authorized to communicate with the CAPI.
306+
// It attempts to reuse a previously saved JWT token from the database, falling back to
307+
// an authentication request if the token is missing, invalid, or expired.
308+
//
309+
// If a new token is obtained, it is saved back to the database for caching.
310+
func (a *apic) Authenticate(ctx context.Context, config *csconfig.OnlineApiClientCfg) error {
311+
if token, exp, valid := loadAPICToken(ctx, a.dbClient); valid {
312+
log.Debug("using valid token from DB")
313+
a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Token = token
314+
a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Expiration = exp
315+
}
316+
317+
log.Debug("No token found, authenticating")
318+
319+
scenarios, err := a.FetchScenariosListFromDB(ctx)
249320
if err != nil {
250-
return ret, fmt.Errorf("get scenario in db: %w", err)
321+
return fmt.Errorf("get scenario in db: %w", err)
251322
}
252323

253-
authResp, _, err := ret.apiClient.Auth.AuthenticateWatcher(ctx, models.WatcherAuthRequest{
324+
password := strfmt.Password(config.Credentials.Password)
325+
326+
authResp, _, err := a.apiClient.Auth.AuthenticateWatcher(ctx, models.WatcherAuthRequest{
254327
MachineID: &config.Credentials.Login,
255328
Password: &password,
256329
Scenarios: scenarios,
257330
})
258331
if err != nil {
259-
return ret, fmt.Errorf("authenticate watcher (%s): %w", config.Credentials.Login, err)
332+
return fmt.Errorf("authenticate watcher (%s): %w", config.Credentials.Login, err)
260333
}
261334

262-
if err = ret.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Expiration.UnmarshalText([]byte(authResp.Expire)); err != nil {
263-
return ret, fmt.Errorf("unable to parse jwt expiration: %w", err)
335+
if err = a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Expiration.UnmarshalText([]byte(authResp.Expire)); err != nil {
336+
return fmt.Errorf("unable to parse jwt expiration: %w", err)
264337
}
265338

266-
ret.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Token = authResp.Token
339+
a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Token = authResp.Token
267340

268-
return ret, err
341+
return saveAPICToken(ctx, a.dbClient, authResp.Token)
269342
}
270343

271344
// keep track of all alerts in cache and push it to CAPI every PushInterval.

pkg/database/ent/migrate/schema.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/database/ent/schema/config.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package schema
22

33
import (
44
"entgo.io/ent"
5+
"entgo.io/ent/dialect"
56
"entgo.io/ent/schema/field"
67

78
"github.com/crowdsecurity/crowdsec/pkg/types"
@@ -22,7 +23,10 @@ func (ConfigItem) Fields() []ent.Field {
2223
Default(types.UtcNow).
2324
UpdateDefault(types.UtcNow).StructTag(`json:"updated_at"`),
2425
field.String("name").Unique().StructTag(`json:"name"`).Immutable(),
25-
field.String("value").StructTag(`json:"value"`), // a json object
26+
field.String("value").SchemaType(map[string]string{
27+
dialect.MySQL: "longtext",
28+
dialect.Postgres: "text",
29+
}).StructTag(`json:"value"`), // a json object
2630
}
2731
}
2832

0 commit comments

Comments
 (0)