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