diff --git a/changes/9119-log-activity-failed-login b/changes/9119-log-activity-failed-login
new file mode 100644
index 0000000000..6ca9b19b85
--- /dev/null
+++ b/changes/9119-log-activity-failed-login
@@ -0,0 +1 @@
+* Log failed login attempts for user+pw and SSO logins (in the activity feed).
diff --git a/docs/Using-Fleet/Audit-Activities.md b/docs/Using-Fleet/Audit-Activities.md
index dbad39cf72..6966938ea3 100644
--- a/docs/Using-Fleet/Audit-Activities.md
+++ b/docs/Using-Fleet/Audit-Activities.md
@@ -377,6 +377,23 @@ This activity contains the following fields:
}
```
+### Type `user_failed_login`
+
+Generated when users try to log in to Fleet and fail.
+
+This activity contains the following fields:
+- "email": The email used in the login request.
+- "public_ip": Public IP of the login request.
+
+#### Example
+
+```json
+{
+ "email": "foo@example.com",
+ "public_ip": "168.226.215.82"
+}
+```
+
### Type `created_user`
Generated when a user is created.
diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts
index 6b943bc010..8f58e75e17 100644
--- a/frontend/interfaces/activity.ts
+++ b/frontend/interfaces/activity.ts
@@ -22,6 +22,7 @@ export enum ActivityType {
EditedAgentOptions = "edited_agent_options",
UserAddedBySSO = "user_added_by_sso",
UserLoggedIn = "user_logged_in",
+ UserFailedLogin = "user_failed_login",
UserCreated = "created_user",
UserDeleted = "deleted_user",
UserChangedGlobalRole = "changed_user_global_role",
@@ -57,6 +58,7 @@ export interface IActivityDetails {
global?: boolean;
public_ip?: string;
user_email?: string;
+ email?: string;
role?: string;
host_serial?: string;
installed_from_dep?: boolean;
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx
index 14700c5323..7561c51847 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx
@@ -213,6 +213,23 @@ describe("Activity Feed", () => {
).toBeInTheDocument();
});
+ it("renders a user_failed_login type activity globally", () => {
+ const activity = createMockActivity({
+ type: ActivityType.UserFailedLogin,
+ details: { email: "foo@example.com", public_ip: "192.168.0.1" },
+ });
+ render();
+
+ expect(
+ screen.getByText(" failed to log in from public IP 192.168.0.1.", {
+ exact: false,
+ })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("foo@example.com", { exact: false })
+ ).toBeInTheDocument();
+ });
+
it("renders a created_user type activity globally", () => {
const activity = createMockActivity({
type: ActivityType.UserCreated,
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
index 0a727588f7..a97d3b7b46 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
@@ -93,6 +93,14 @@ const TAGGED_TEMPLATES = {
userLoggedIn: (activity: IActivity) => {
return `successfully logged in from public IP ${activity.details?.public_ip}.`;
},
+ userFailedLogin: (activity: IActivity) => {
+ return (
+ <>
+ Somebody using {activity.details?.email} failed to log in from
+ public IP {activity.details?.public_ip}.
+ >
+ );
+ },
userCreated: (activity: IActivity) => {
return (
<>
@@ -217,6 +225,9 @@ const getDetail = (
case ActivityType.UserLoggedIn: {
return TAGGED_TEMPLATES.userLoggedIn(activity);
}
+ case ActivityType.UserFailedLogin: {
+ return TAGGED_TEMPLATES.userFailedLogin(activity);
+ }
case ActivityType.UserCreated: {
return TAGGED_TEMPLATES.userCreated(activity);
}
diff --git a/server/fleet/activities.go b/server/fleet/activities.go
index bcbae2e37e..ab87ba124c 100644
--- a/server/fleet/activities.go
+++ b/server/fleet/activities.go
@@ -34,6 +34,7 @@ var ActivityDetailsList = []ActivityDetails{
ActivityTypeUserAddedBySSO{},
ActivityTypeUserLoggedIn{},
+ ActivityTypeUserFailedLogin{},
ActivityTypeCreatedUser{},
ActivityTypeDeletedUser{},
@@ -457,6 +458,25 @@ func (a ActivityTypeUserLoggedIn) Documentation() (activity string, details stri
}`
}
+type ActivityTypeUserFailedLogin struct {
+ Email string `json:"email"`
+ PublicIP string `json:"public_ip"`
+}
+
+func (a ActivityTypeUserFailedLogin) ActivityName() string {
+ return "user_failed_login"
+}
+
+func (a ActivityTypeUserFailedLogin) Documentation() (activity string, details string, detailsExample string) {
+ return `Generated when users try to log in to Fleet and fail.`,
+ `This activity contains the following fields:
+- "email": The email used in the login request.
+- "public_ip": Public IP of the login request.`, `{
+ "email": "foo@example.com",
+ "public_ip": "168.226.215.82"
+}`
+}
+
type ActivityTypeCreatedUser struct {
UserID uint `json:"user_id"`
UserName string `json:"user_name"`
diff --git a/server/fleet/service.go b/server/fleet/service.go
index 0d95422a03..8ba3392c44 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -460,6 +460,15 @@ type Service interface {
///////////////////////////////////////////////////////////////////////////////
// ActivitiesService
+ // NewActivity creates the given activity on the datastore.
+ //
+ // What we call "Activities" are administrative operations,
+ // logins, running a live query, etc.
+ NewActivity(ctx context.Context, user *User, activity ActivityDetails) error
+ // ListActivities lists the activities stored in the datastore.
+ //
+ // What we call "Activities" are administrative operations,
+ // logins, running a live query, etc.
ListActivities(ctx context.Context, opt ListActivitiesOptions) ([]*Activity, *PaginationMetadata, error)
///////////////////////////////////////////////////////////////////////////////
diff --git a/server/service/activities.go b/server/service/activities.go
index 7b7e714ab8..493b33ee4c 100644
--- a/server/service/activities.go
+++ b/server/service/activities.go
@@ -120,3 +120,7 @@ func logRoleChangeActivities(ctx context.Context, ds fleet.Datastore, adminUser
}
return nil
}
+
+func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
+ return svc.ds.NewActivity(ctx, user, activity)
+}
diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go
index f30c84af23..30f62e4cc1 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -5512,6 +5512,74 @@ func (s *integrationTestSuite) TestCarve() {
checkCarveError(1, "block_id exceeds expected max (2): 3")
}
+func (s *integrationTestSuite) TestLogLoginAttempts() {
+ t := s.T()
+
+ // create a new user
+ var createResp createUserResponse
+ params := fleet.UserPayload{
+ Name: ptr.String("foobar"),
+ Email: ptr.String("foobar@example.com"),
+ Password: ptr.String(test.GoodPassword),
+ GlobalRole: ptr.String(fleet.RoleObserver),
+ }
+ s.DoJSON("POST", "/api/latest/fleet/users/admin", params, http.StatusOK, &createResp)
+ require.NotZero(t, createResp.User.ID)
+ u := *createResp.User
+
+ // Register current number of activities.
+ activitiesResp := listActivitiesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp)
+ require.NoError(t, activitiesResp.Err)
+ oldActivitiesCount := len(activitiesResp.Activities)
+
+ // Login with invalid passwordm, should fail.
+ res := s.DoRawNoAuth("POST", "/api/latest/fleet/login",
+ jsonMustMarshal(t, loginRequest{Email: u.Email, Password: test.GoodPassword2}),
+ http.StatusUnauthorized,
+ )
+ res.Body.Close()
+
+ // A new activity item for the failed login attempt is created.
+ activitiesResp = listActivitiesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp)
+ require.NoError(t, activitiesResp.Err)
+ require.Len(t, activitiesResp.Activities, oldActivitiesCount+1)
+ sort.Slice(activitiesResp.Activities, func(i, j int) bool {
+ return activitiesResp.Activities[i].ID < activitiesResp.Activities[j].ID
+ })
+ activity := activitiesResp.Activities[len(activitiesResp.Activities)-1]
+ require.Equal(t, activity.Type, fleet.ActivityTypeUserFailedLogin{}.ActivityName())
+ require.NotNil(t, activity.Details)
+ actDetails := fleet.ActivityTypeUserFailedLogin{}
+ err := json.Unmarshal(*activity.Details, &actDetails)
+ require.NoError(t, err)
+ require.Equal(t, actDetails.Email, "foobar@example.com")
+
+ // login with good password, should succeed
+ res = s.DoRawNoAuth("POST", "/api/latest/fleet/login",
+ jsonMustMarshal(t, loginRequest{
+ Email: u.Email,
+ Password: test.GoodPassword,
+ }), http.StatusOK,
+ )
+ res.Body.Close()
+
+ // A new activity item for the successful login is created.
+ activitiesResp = listActivitiesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp)
+ require.NoError(t, activitiesResp.Err)
+ require.Len(t, activitiesResp.Activities, oldActivitiesCount+2)
+ sort.Slice(activitiesResp.Activities, func(i, j int) bool {
+ return activitiesResp.Activities[i].ID < activitiesResp.Activities[j].ID
+ })
+ activity = activitiesResp.Activities[len(activitiesResp.Activities)-1]
+ require.Equal(t, activity.Type, fleet.ActivityTypeUserLoggedIn{}.ActivityName())
+ require.NotNil(t, activity.Details)
+ err = json.Unmarshal(*activity.Details, &fleet.ActivityTypeUserLoggedIn{})
+ require.NoError(t, err)
+}
+
func (s *integrationTestSuite) TestPasswordReset() {
t := s.T()
diff --git a/server/service/integration_sso_test.go b/server/service/integration_sso_test.go
index 461d83daf8..a149f4e81c 100644
--- a/server/service/integration_sso_test.go
+++ b/server/service/integration_sso_test.go
@@ -9,6 +9,7 @@ import (
"encoding/xml"
"net/http"
"net/url"
+ "sort"
"strings"
"testing"
@@ -94,10 +95,39 @@ func (s *integrationSSOTestSuite) TestSSOLogin() {
}`), http.StatusOK, &acResp)
require.NotNil(t, acResp)
+ // Register current number of activities.
+ activitiesResp := listActivitiesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp)
+ require.NoError(t, activitiesResp.Err)
+ oldActivitiesCount := len(activitiesResp.Activities)
+
// users can't login if they don't have an account on free plans
_, body := s.LoginSSOUser("sso_user", "user123#")
require.Contains(t, body, "/login?status=account_invalid")
+ newActivitiesCount := 1
+ checkNewFailedLoginActivity := func() {
+ activitiesResp = listActivitiesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp)
+ require.NoError(t, activitiesResp.Err)
+ require.Len(t, activitiesResp.Activities, oldActivitiesCount+newActivitiesCount)
+ sort.Slice(activitiesResp.Activities, func(i, j int) bool {
+ return activitiesResp.Activities[i].ID < activitiesResp.Activities[j].ID
+ })
+ activity := activitiesResp.Activities[len(activitiesResp.Activities)-1]
+ require.Equal(t, activity.Type, fleet.ActivityTypeUserFailedLogin{}.ActivityName())
+ require.NotNil(t, activity.Details)
+ actDetails := fleet.ActivityTypeUserFailedLogin{}
+ err := json.Unmarshal(*activity.Details, &actDetails)
+ require.NoError(t, err)
+ require.Equal(t, "sso_user@example.com", actDetails.Email)
+
+ newActivitiesCount++
+ }
+
+ // A new activity item for the failed SSO login is created.
+ checkNewFailedLoginActivity()
+
// users can't login if they don't have an account on free plans
// even if JIT provisioning is enabled
ac, err := s.ds.AppConfig(context.Background())
@@ -108,6 +138,9 @@ func (s *integrationSSOTestSuite) TestSSOLogin() {
_, body = s.LoginSSOUser("sso_user", "user123#")
require.Contains(t, body, "/login?status=account_invalid")
+ // A new activity item for the failed SSO login is created.
+ checkNewFailedLoginActivity()
+
// an user created by an admin without SSOEnabled can't log-in
params := fleet.UserPayload{
Name: ptr.String("SSO User 1"),
@@ -119,6 +152,9 @@ func (s *integrationSSOTestSuite) TestSSOLogin() {
_, body = s.LoginSSOUser("sso_user", "user123#")
require.Contains(t, body, "/login?status=account_invalid")
+ // A new activity item for the failed SSO login is created.
+ checkNewFailedLoginActivity()
+
// an user created by an admin with SSOEnabled is able to log-in
params = fleet.UserPayload{
Name: ptr.String("SSO User 2"),
@@ -133,7 +169,7 @@ func (s *integrationSSOTestSuite) TestSSOLogin() {
require.Contains(t, body, "Redirecting to Fleet at ...")
// a new activity item is created
- activitiesResp := listActivitiesResponse{}
+ activitiesResp = listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp)
require.NoError(t, activitiesResp.Err)
require.NotEmpty(t, activitiesResp.Activities)
diff --git a/server/service/sessions.go b/server/service/sessions.go
index 750b4b84f5..79e274ea0b 100644
--- a/server/service/sessions.go
+++ b/server/service/sessions.go
@@ -166,6 +166,14 @@ func (svc *Service) Login(ctx context.Context, email, password string) (*fleet.U
var err error
defer func(start time.Time) {
if err != nil {
+ if err := svc.ds.NewActivity(ctx, nil, fleet.ActivityTypeUserFailedLogin{
+ Email: email,
+ PublicIP: publicip.FromContext(ctx),
+ }); err != nil {
+ logging.WithExtras(logging.WithNoUser(ctx),
+ "msg", "failed to generate failed login activity",
+ )
+ }
time.Sleep(time.Until(start.Add(1 * time.Second)))
}
}(time.Now())
@@ -361,6 +369,15 @@ func makeCallbackSSOEndpoint(urlPrefix string) handlerFunc {
session, err := getSSOSession(ctx, svc, authResponse)
var resp callbackSSOResponse
if err != nil {
+ if err := svc.NewActivity(ctx, nil, fleet.ActivityTypeUserFailedLogin{
+ Email: authResponse.UserID(),
+ PublicIP: publicip.FromContext(ctx),
+ }); err != nil {
+ logging.WithLevel(logging.WithExtras(logging.WithNoUser(ctx),
+ "msg", "failed to generate failed login activity",
+ ), level.Info)
+ }
+
var ssoErr ssoError
status := ssoOtherError