Log failed login attempts as activities (#9430)
#9119 To test the SSO changes locally you can use: https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Testing-and-local-development.md#testing-sso @RachelElysia Please take a look at the UI changes (All I did was copy/paste and amend the changes for the new activity type.) IMO we shouldn't display an avatar because there's no "actual user" involved in these failed login attempts activities (by "actual user" I mean the user attributed to the activity): <img width="446" alt="Screenshot 2023-01-19 at 10 41 05" src="https://user-images.githubusercontent.com/2073526/213524771-b85901ce-eec0-4cf3-919c-73162285e20b.png"> - [X] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [X] Documented any API changes (docs/Using-Fleet/REST-API.md or docs/Contributing/API-for-contributors.md) - ~[ ] Documented any permissions changes~ - ~[ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements)~ - ~[ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features.~ - [X] Added/updated tests - [X] Manual QA for all new/changed functionality - ~For Orbit and Fleet Desktop changes:~ - ~[ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux.~ - ~[ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).~
This commit is contained in:
@@ -0,0 +1 @@
|
||||
* Log failed login attempts for user+pw and SSO logins (in the activity feed).
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(<ActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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 <b>{activity.details?.email}</b> 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);
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user