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:
Lucas Manuel Rodriguez
2023-01-20 12:43:22 -03:00
committed by GitHub
parent a55a9789e6
commit 328004d679
11 changed files with 203 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
* Log failed login attempts for user+pw and SSO logins (in the activity feed).
+17
View File
@@ -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.
+2
View File
@@ -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);
}
+20
View File
@@ -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"`
+9
View File
@@ -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)
///////////////////////////////////////////////////////////////////////////////
+4
View File
@@ -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)
}
+68
View File
@@ -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()
+37 -1
View File
@@ -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)
+17
View File
@@ -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