Skip to content

Commit 14a1725

Browse files
authored
cscli capi status: save auth token, add tests (#3623)
* "cscli capi status": save auth token * CI: test auth token cache * test that capi status saves the token too * fix postgres test
1 parent 073a35d commit 14a1725

File tree

7 files changed

+158
-71
lines changed

7 files changed

+158
-71
lines changed

cmd/crowdsec-cli/clicapi/capi.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/crowdsecurity/crowdsec/pkg/apiclient"
2222
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
2323
"github.com/crowdsecurity/crowdsec/pkg/cwhub"
24+
"github.com/crowdsecurity/crowdsec/pkg/database"
2425
"github.com/crowdsecurity/crowdsec/pkg/models"
2526
"github.com/crowdsecurity/crowdsec/pkg/types"
2627
)
@@ -120,7 +121,7 @@ func (cli *cliCapi) register(ctx context.Context, capiUserPrefix string, outputF
120121

121122
log.Infof("Central API credentials written to '%s'", dumpFile)
122123
} else {
123-
fmt.Println(string(apiConfigDump))
124+
fmt.Fprintln(os.Stdout, string(apiConfigDump))
124125
}
125126

126127
if msg := reload.UserMessage(); msg != "" {
@@ -154,8 +155,8 @@ func (cli *cliCapi) newRegisterCmd() *cobra.Command {
154155
return cmd
155156
}
156157

157-
// queryCAPIStatus checks if the Central API is reachable, and if the credentials are correct. It then checks if the instance is enrolle in the console.
158-
func queryCAPIStatus(ctx context.Context, hub *cwhub.Hub, credURL string, login string, password string) (bool, bool, error) {
158+
// queryCAPIStatus checks if the Central API is reachable, and if the credentials are correct. It then checks if the instance is enrolled in the console.
159+
func queryCAPIStatus(ctx context.Context, db *database.Client, hub *cwhub.Hub, credURL string, login string, password string) (bool, bool, error) {
159160
apiURL, err := url.Parse(credURL)
160161
if err != nil {
161162
return false, false, err
@@ -198,6 +199,10 @@ func queryCAPIStatus(ctx context.Context, hub *cwhub.Hub, credURL string, login
198199
return false, false, err
199200
}
200201

202+
if err := db.SaveAPICToken(ctx, authResp.Token); err != nil {
203+
return false, false, err
204+
}
205+
201206
client.GetClient().Transport.(*apiclient.JWTTransport).Token = authResp.Token
202207

203208
if client.IsEnrolled() {
@@ -207,7 +212,7 @@ func queryCAPIStatus(ctx context.Context, hub *cwhub.Hub, credURL string, login
207212
return true, false, nil
208213
}
209214

210-
func (cli *cliCapi) Status(ctx context.Context, out io.Writer, hub *cwhub.Hub) error {
215+
func (cli *cliCapi) Status(ctx context.Context, db *database.Client, out io.Writer, hub *cwhub.Hub) error {
211216
cfg := cli.cfg()
212217

213218
if err := require.CAPIRegistered(cfg); err != nil {
@@ -219,7 +224,7 @@ func (cli *cliCapi) Status(ctx context.Context, out io.Writer, hub *cwhub.Hub) e
219224
fmt.Fprintf(out, "Loaded credentials from %s\n", cfg.API.Server.OnlineClient.CredentialsFilePath)
220225
fmt.Fprintf(out, "Trying to authenticate with username %s on %s\n", cred.Login, cred.URL)
221226

222-
auth, enrolled, err := queryCAPIStatus(ctx, hub, cred.URL, cred.Login, cred.Password)
227+
auth, enrolled, err := queryCAPIStatus(ctx, db, hub, cred.URL, cred.Login, cred.Password)
223228
if err != nil {
224229
return fmt.Errorf("failed to authenticate to Central API (CAPI): %w", err)
225230
}
@@ -263,12 +268,20 @@ func (cli *cliCapi) newStatusCmd() *cobra.Command {
263268
Args: args.NoArgs,
264269
DisableAutoGenTag: true,
265270
RunE: func(cmd *cobra.Command, _ []string) error {
266-
hub, err := require.Hub(cli.cfg(), nil)
271+
cfg := cli.cfg()
272+
ctx := cmd.Context()
273+
274+
hub, err := require.Hub(cfg, nil)
275+
if err != nil {
276+
return err
277+
}
278+
279+
db, err := require.DBClient(ctx, cfg.DbConfig)
267280
if err != nil {
268281
return err
269282
}
270283

271-
return cli.Status(cmd.Context(), color.Output, hub)
284+
return cli.Status(ctx, db, color.Output, hub)
272285
},
273286
}
274287

cmd/crowdsec-cli/clisupport/support.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,13 @@ func (cli *cliSupport) dumpLAPIStatus(ctx context.Context, zw *zip.Writer, hub *
256256
return nil
257257
}
258258

259-
func (cli *cliSupport) dumpCAPIStatus(ctx context.Context, zw *zip.Writer, hub *cwhub.Hub) error {
259+
func (cli *cliSupport) dumpCAPIStatus(ctx context.Context, zw *zip.Writer, hub *cwhub.Hub, db *database.Client) error {
260260
log.Info("Collecting CAPI status")
261261

262262
out := new(bytes.Buffer)
263263
cc := clicapi.New(cli.cfg)
264264

265-
err := cc.Status(ctx, out, hub)
265+
err := cc.Status(ctx, db, out, hub)
266266
if err != nil {
267267
fmt.Fprintf(out, "%s\n", err)
268268
}
@@ -534,7 +534,7 @@ func (cli *cliSupport) dump(ctx context.Context, outFile string) error {
534534
}
535535

536536
if !skipCAPI {
537-
if err = cli.dumpCAPIStatus(ctx, zipWriter, hub); err != nil {
537+
if err = cli.dumpCAPIStatus(ctx, zipWriter, hub, db); err != nil {
538538
log.Warnf("could not collect CAPI status: %s", err)
539539
}
540540

pkg/apiserver/apic.go

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

1818
"github.com/davecgh/go-spew/spew"
1919
"github.com/go-openapi/strfmt"
20-
"github.com/golang-jwt/jwt/v4"
2120
log "github.com/sirupsen/logrus"
2221
"gopkg.in/tomb.v2"
2322

@@ -247,70 +246,13 @@ func NewAPIC(ctx context.Context, config *csconfig.OnlineApiClientCfg, dbClient
247246
return ret, err
248247
}
249248

250-
// loadAPICToken attempts to retrieve and validate a JWT token from the local database.
251-
// It returns the token string, its expiration time, and a boolean indicating whether the token is valid.
252-
//
253-
// A token is considered valid if:
254-
// - it exists in the database,
255-
// - it is a properly formatted JWT with an "exp" claim,
256-
// - it is not expired or near expiry.
257-
func loadAPICToken(ctx context.Context, db *database.Client) (string, time.Time, bool) {
258-
token, err := db.GetConfigItem(ctx, "apic_token")
259-
if err != nil {
260-
log.Debugf("error fetching token from DB: %s", err)
261-
return "", time.Time{}, false
262-
}
263-
264-
if token == "" {
265-
log.Debug("no token found in DB")
266-
return "", time.Time{}, false
267-
}
268-
269-
parser := new(jwt.Parser)
270-
271-
tok, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
272-
if err != nil {
273-
log.Debugf("error parsing token: %s", err)
274-
return "", time.Time{}, false
275-
}
276-
277-
claims, ok := tok.Claims.(jwt.MapClaims)
278-
if !ok {
279-
log.Debugf("error parsing token claims: %s", err)
280-
return "", time.Time{}, false
281-
}
282-
283-
expFloat, ok := claims["exp"].(float64)
284-
if !ok {
285-
log.Debug("token missing 'exp' claim")
286-
return "", time.Time{}, false
287-
}
288-
289-
exp := time.Unix(int64(expFloat), 0)
290-
if time.Now().UTC().After(exp.Add(-1 * time.Minute)) {
291-
log.Debug("auth token expired")
292-
return "", time.Time{}, false
293-
}
294-
295-
return token, exp, true
296-
}
297-
298-
// saveAPICToken stores the given JWT token in the local database under the "apic_token" config item.
299-
func saveAPICToken(ctx context.Context, db *database.Client, token string) error {
300-
if err := db.SetConfigItem(ctx, "apic_token", token); err != nil {
301-
return fmt.Errorf("saving token to db: %w", err)
302-
}
303-
304-
return nil
305-
}
306-
307249
// Authenticate ensures the API client is authorized to communicate with the CAPI.
308250
// It attempts to reuse a previously saved JWT token from the database, falling back to
309251
// an authentication request if the token is missing, invalid, or expired.
310252
//
311253
// If a new token is obtained, it is saved back to the database for caching.
312254
func (a *apic) Authenticate(ctx context.Context, config *csconfig.OnlineApiClientCfg) error {
313-
if token, exp, valid := loadAPICToken(ctx, a.dbClient); valid {
255+
if token, exp, valid := a.dbClient.LoadAPICToken(ctx, log.StandardLogger()); valid {
314256
log.Debug("using valid token from DB")
315257

316258
a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Token = token
@@ -343,7 +285,7 @@ func (a *apic) Authenticate(ctx context.Context, config *csconfig.OnlineApiClien
343285

344286
a.apiClient.GetClient().Transport.(*apiclient.JWTTransport).Token = authResp.Token
345287

346-
return saveAPICToken(ctx, a.dbClient, authResp.Token)
288+
return a.dbClient.SaveAPICToken(ctx, authResp.Token)
347289
}
348290

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

pkg/database/config.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ package database
22

33
import (
44
"context"
5+
"fmt"
6+
"time"
57

8+
"github.com/golang-jwt/jwt/v4"
69
"github.com/pkg/errors"
10+
"github.com/sirupsen/logrus"
711

812
"github.com/crowdsecurity/crowdsec/pkg/database/ent"
913
"github.com/crowdsecurity/crowdsec/pkg/database/ent/configitem"
1014
)
1115

16+
const apicTokenKey = "apic_token"
17+
1218
func (c *Client) GetConfigItem(ctx context.Context, key string) (string, error) {
1319
result, err := c.Ent.ConfigItem.Query().Where(configitem.NameEQ(key)).First(ctx)
1420

@@ -38,3 +44,60 @@ func (c *Client) SetConfigItem(ctx context.Context, key string, value string) er
3844

3945
return nil
4046
}
47+
48+
// LoadAPICToken attempts to retrieve and validate a JWT token from the local database.
49+
// It returns the token string, its expiration time, and a boolean indicating whether the token is valid.
50+
//
51+
// A token is considered valid if:
52+
// - it exists in the database,
53+
// - it is a properly formatted JWT with an "exp" claim,
54+
// - it is not expired or near expiry.
55+
func (c *Client) LoadAPICToken(ctx context.Context, logger logrus.FieldLogger) (string, time.Time, bool) {
56+
token, err := c.GetConfigItem(ctx, apicTokenKey)
57+
if err != nil {
58+
logger.Debugf("error fetching token from DB: %s", err)
59+
return "", time.Time{}, false
60+
}
61+
62+
if token == "" {
63+
logger.Debug("no token found in DB")
64+
return "", time.Time{}, false
65+
}
66+
67+
parser := new(jwt.Parser)
68+
69+
tok, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
70+
if err != nil {
71+
logger.Debugf("error parsing token: %s", err)
72+
return "", time.Time{}, false
73+
}
74+
75+
claims, ok := tok.Claims.(jwt.MapClaims)
76+
if !ok {
77+
logger.Debugf("error parsing token claims: %s", err)
78+
return "", time.Time{}, false
79+
}
80+
81+
expFloat, ok := claims["exp"].(float64)
82+
if !ok {
83+
logger.Debug("token missing 'exp' claim")
84+
return "", time.Time{}, false
85+
}
86+
87+
exp := time.Unix(int64(expFloat), 0)
88+
if time.Now().UTC().After(exp.Add(-1 * time.Minute)) {
89+
logger.Debug("auth token expired")
90+
return "", time.Time{}, false
91+
}
92+
93+
return token, exp, true
94+
}
95+
96+
// SaveAPICToken stores the given JWT token in the local database under the appropriate config item.
97+
func (c *Client) SaveAPICToken(ctx context.Context, token string) error {
98+
if err := c.SetConfigItem(ctx, apicTokenKey, token); err != nil {
99+
return fmt.Errorf("saving token to db: %w", err)
100+
}
101+
102+
return nil
103+
}

test/bats/04_capi.bats

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ setup() {
4242

4343
config_set 'del(.api.server.online_client)'
4444
rune -1 cscli capi status
45-
assert_stderr --regexp "no configuration for Central API \(CAPI\) in '$(echo $CONFIG_YAML|sed s#//#/#g)'"
45+
assert_stderr --regexp "no configuration for Central API \(CAPI\) in '${CONFIG_YAML//\/\//\/}'"
4646
}
4747

4848
@test "cscli {capi,papi} status" {
@@ -100,6 +100,49 @@ setup() {
100100
assert_output --partial "You can successfully interact with Central API (CAPI)"
101101
}
102102

103+
@test "CAPI login: use cached token from the db" {
104+
./instance-crowdsec stop
105+
106+
config_set '.common.log_media="stdout" | .common.log_level="debug"'
107+
108+
# a correct token was set in the previous test
109+
110+
rune -0 wait-for \
111+
--err "CAPI manager configured successfully" \
112+
"$CROWDSEC"
113+
assert_stderr --partial "using valid token from DB"
114+
refute_stderr --partial "No token found, authenticating"
115+
116+
# not valid anymore
117+
118+
rune -0 ./instance-db exec_sql "UPDATE config_items SET VALUE='abc' WHERE name='apic_token'"
119+
120+
rune -0 wait-for \
121+
--err "CAPI manager configured successfully" \
122+
"$CROWDSEC"
123+
refute_stderr --partial "using valid token from DB"
124+
assert_stderr --partial "error parsing token: token contains an invalid number of segments"
125+
assert_stderr --partial "No token found, authenticating"
126+
127+
# token was re-created
128+
129+
rune -0 wait-for \
130+
--err "CAPI manager configured successfully" \
131+
"$CROWDSEC"
132+
assert_stderr --partial "using valid token from DB"
133+
refute_stderr --partial "No token found, authenticating"
134+
135+
# "cscli capi status" also saves the token
136+
137+
rune -0 ./instance-db exec_sql "UPDATE config_items SET VALUE='abc' WHERE name='apic_token'"
138+
rune -0 cscli capi status
139+
rune -0 wait-for \
140+
--err "CAPI manager configured successfully" \
141+
"$CROWDSEC"
142+
assert_stderr --partial "using valid token from DB"
143+
refute_stderr --partial "No token found, authenticating"
144+
}
145+
103146
@test "capi register must be run from lapi" {
104147
config_disable_lapi
105148
rune -1 cscli capi register --schmilblick githubciXXXXXXXXXXXXXXXXXXXXXXXX

test/bats/sql.bats

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env bats
2+
3+
set -u
4+
5+
setup_file() {
6+
load "../lib/setup_file.sh"
7+
}
8+
9+
teardown_file() {
10+
load "../lib/teardown_file.sh"
11+
}
12+
13+
setup() {
14+
load "../lib/setup.sh"
15+
load "../lib/bats-file/load.bash"
16+
./instance-data load
17+
}
18+
19+
#----------
20+
21+
@test "sql helper" {
22+
rune -0 ./instance-db exec_sql "SELECT 11235813"
23+
assert_output --partial '11235813'
24+
}

test/lib/db/instance-postgres

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ case "$1" in
9191
restore "$@"
9292
;;
9393
exec_sql)
94+
PGDATABASE=${PGDATABASE:-crowdsec_test}
95+
export PGDATABASE
9496
shift
9597
exec_sql "$@"
9698
;;

0 commit comments

Comments
 (0)