diff --git a/server/fleet/api_sessions.go b/server/fleet/api_sessions.go new file mode 100644 index 0000000000..a1b0b2ece9 --- /dev/null +++ b/server/fleet/api_sessions.go @@ -0,0 +1,39 @@ +package fleet + +import ( + "time" +) + +//////////////////////////////////////////////////////////////////////////////// +// Login +//////////////////////////////////////////////////////////////////////////////// + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + // If false/omitted, users that require email verification (Fleet MFA) to log in will fail to log in, rather than + // sending an MFA email, since the MFA email will land the user in a browser and complete the login there, rather + // than e.g. in the CLI that initiated the login. As with SSO, the expected behavior for users with MFA is to log + // in with MFA, then grab an API token for use elsewhere. + SupportsEmailVerification bool `json:"supports_email_verification"` +} + +type LoginResponse struct { + User *User `json:"user,omitempty"` + AvailableTeams []*TeamSummary `json:"available_teams" renameto:"available_fleets"` + Token string `json:"token,omitempty"` + TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r LoginResponse) Error() error { return r.Err } + +//////////////////////////////////////////////////////////////////////////////// +// Logout +//////////////////////////////////////////////////////////////////////////////// + +type LogoutResponse struct { + Err error `json:"error,omitempty"` +} + +func (r LogoutResponse) Error() error { return r.Err } diff --git a/server/service/client_sessions.go b/server/service/client_sessions.go index a43aa0aa4a..08312a27bd 100644 --- a/server/service/client_sessions.go +++ b/server/service/client_sessions.go @@ -5,13 +5,13 @@ import ( "fmt" "net/http" - "github.com/fleetdm/fleet/v4/server/service/contract" + "github.com/fleetdm/fleet/v4/server/fleet" ) // Login attempts to login to the current Fleet instance. If login is successful, // an auth token is returned. func (c *Client) Login(email, password string) (string, error) { - params := contract.LoginRequest{ + params := fleet.LoginRequest{ Email: email, Password: password, } @@ -33,7 +33,7 @@ func (c *Client) Login(email, password string) (string, error) { ) } - var responseBody loginResponse + var responseBody fleet.LoginResponse err = json.NewDecoder(response.Body).Decode(&responseBody) if err != nil { return "", fmt.Errorf("decode login response: %w", err) @@ -49,6 +49,6 @@ func (c *Client) Login(email, password string) (string, error) { // Logout attempts to logout to the current Fleet instance. func (c *Client) Logout() error { verb, path := "POST", "/api/latest/fleet/logout" - var responseBody logoutResponse + var responseBody fleet.LogoutResponse return c.authenticatedRequest(nil, verb, path, &responseBody) } diff --git a/server/service/contract/sessions.go b/server/service/contract/sessions.go deleted file mode 100644 index 5320c5e507..0000000000 --- a/server/service/contract/sessions.go +++ /dev/null @@ -1,11 +0,0 @@ -package contract - -type LoginRequest struct { - Email string `json:"email"` - Password string `json:"password"` - // If false/omitted, users that require email verification (Fleet MFA) to log in will fail to log in, rather than - // sending an MFA email, since the MFA email will land the user in a browser and complete the login there, rather - // than e.g. in the CLI that initiated the login. As with SSO, the expected behavior for users with MFA is to log - // in with MFA, then grab an API token for use elsewhere. - SupportsEmailVerification bool `json:"supports_email_verification"` -} diff --git a/server/service/handler.go b/server/service/handler.go index 6fcf5e46f0..14a20e4791 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -1168,7 +1168,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC } ne.WithCustomMiddleware(loginLimiter). - POST("/api/_version_/fleet/login", loginEndpoint, contract.LoginRequest{}) + POST("/api/_version_/fleet/login", loginEndpoint, fleet.LoginRequest{}) ne.WithCustomMiddleware(limiter.Limit("mfa", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})). POST("/api/_version_/fleet/sessions", sessionCreateEndpoint, sessionCreateRequest{}) diff --git a/server/service/http_auth_test.go b/server/service/http_auth_test.go index 007145bfd1..faac9182b1 100644 --- a/server/service/http_auth_test.go +++ b/server/service/http_auth_test.go @@ -18,7 +18,6 @@ import ( "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" - "github.com/fleetdm/fleet/v4/server/service/contract" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -56,7 +55,7 @@ func TestLogin(t *testing.T) { // test sessions testUser := users[tt.email] - params := contract.LoginRequest{ + params := fleet.LoginRequest{ Email: tt.email, Password: tt.password, } @@ -192,7 +191,7 @@ func getTestAdminToken(t *testing.T, server *httptest.Server) string { func getTestUserToken(t *testing.T, server *httptest.Server, testUserId string) string { testUser := testUsers[testUserId] - params := contract.LoginRequest{ + params := fleet.LoginRequest{ Email: testUser.Email, Password: testUser.PlaintextPassword, } diff --git a/server/service/integration_android_certificate_templates_test.go b/server/service/integration_android_certificate_templates_test.go index 89346f423a..9b7bb20e96 100644 --- a/server/service/integration_android_certificate_templates_test.go +++ b/server/service/integration_android_certificate_templates_test.go @@ -20,7 +20,6 @@ import ( apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" "github.com/fleetdm/fleet/v4/server/ptr" - "github.com/fleetdm/fleet/v4/server/service/contract" scep_server "github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server" "github.com/fleetdm/fleet/v4/server/worker" "github.com/google/uuid" @@ -1398,8 +1397,8 @@ func (s *integrationMDMTestSuite) TestCertificateTemplateAuthorizationForTeamUse caID := ca.ID // Login as team admin - var loginResp loginResponse - s.DoJSON("POST", "/api/latest/fleet/login", contract.LoginRequest{ + var loginResp fleet.LoginResponse + s.DoJSON("POST", "/api/latest/fleet/login", fleet.LoginRequest{ Email: teamAdminEmail, Password: teamAdminPassword, }, http.StatusOK, &loginResp) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index caf1a5fe9a..d5ce1ead38 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -681,7 +681,7 @@ func (s *integrationTestSuite) TestActivityUserEmailPersistsAfterDeletion() { assert.True(t, createResp.User.AdminForcedPasswordReset) u := *createResp.User - var loginResp loginResponse + var loginResp fleet.LoginResponse s.DoJSON("POST", "/api/latest/fleet/login", params, http.StatusOK, &loginResp) require.Equal(t, loginResp.User.ID, u.ID) @@ -734,8 +734,8 @@ func (s *integrationTestSuite) TestPremiumOnlyRoles() { _, err = s.ds.NewUser(t.Context(), user) require.NoError(t, err) - var loginResp loginResponse - s.DoJSON("POST", "/api/latest/fleet/login", contract.LoginRequest{ + var loginResp fleet.LoginResponse + s.DoJSON("POST", "/api/latest/fleet/login", fleet.LoginRequest{ Email: fmt.Sprintf("%s@example.com", role), Password: test.GoodPassword, }, http.StatusPaymentRequired, &loginResp) @@ -6437,7 +6437,7 @@ func (s *integrationTestSuite) TestUsers() { assert.True(t, createResp.User.AdminForcedPasswordReset) u := *createResp.User - var loginResp loginResponse + var loginResp fleet.LoginResponse // try MFA mysqltest.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { @@ -6449,7 +6449,7 @@ func (s *integrationTestSuite) TestUsers() { s.DoJSONWithoutAuth("POST", "/api/latest/fleet/login", params, http.StatusBadRequest, &loginResp) // MFA supported; send email s.DoJSONWithoutAuth("POST", "/api/latest/fleet/login", - contract.LoginRequest{Email: "extra@asd.com", Password: userRawPwd, SupportsEmailVerification: true}, http.StatusAccepted, &loginResp) + fleet.LoginRequest{Email: "extra@asd.com", Password: userRawPwd, SupportsEmailVerification: true}, http.StatusAccepted, &loginResp) var mfaToken string mysqltest.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { return sqlx.GetContext(context.Background(), tx, &mfaToken, `SELECT token FROM verification_tokens WHERE user_id = ? LIMIT 1`, createResp.User.ID) @@ -6461,7 +6461,7 @@ func (s *integrationTestSuite) TestUsers() { // send another email, which we'll expire the token for s.DoJSONWithoutAuth("POST", "/api/latest/fleet/login", - contract.LoginRequest{Email: "extra@asd.com", Password: userRawPwd, SupportsEmailVerification: true}, http.StatusAccepted, &loginResp) + fleet.LoginRequest{Email: "extra@asd.com", Password: userRawPwd, SupportsEmailVerification: true}, http.StatusAccepted, &loginResp) mysqltest.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { _, err := db.ExecContext( context.Background(), @@ -6587,13 +6587,13 @@ func (s *integrationTestSuite) TestUsers() { s.token = s.getTestAdminToken() // login as that user to verify that the new password is active (userRawPwd was updated to the new pwd) - loginResp = loginResponse{} - s.DoJSON("POST", "/api/latest/fleet/login", contract.LoginRequest{Email: u.Email, Password: userRawPwd}, http.StatusOK, &loginResp) + loginResp = fleet.LoginResponse{} + s.DoJSON("POST", "/api/latest/fleet/login", fleet.LoginRequest{Email: u.Email, Password: userRawPwd}, http.StatusOK, &loginResp) require.Equal(t, loginResp.User.ID, u.ID) // logout for that user s.token = loginResp.Token - var logoutResp logoutResponse + var logoutResp fleet.LogoutResponse s.DoJSON("POST", "/api/latest/fleet/logout", nil, http.StatusOK, &logoutResp) // logout again, even though not logged in @@ -6602,8 +6602,8 @@ func (s *integrationTestSuite) TestUsers() { s.token = s.getTestAdminToken() // login as that user with previous pwd fails - loginResp = loginResponse{} - s.DoJSON("POST", "/api/latest/fleet/login", contract.LoginRequest{Email: u.Email, Password: oldUserRawPwd}, http.StatusUnauthorized, &loginResp) + loginResp = fleet.LoginResponse{} + s.DoJSON("POST", "/api/latest/fleet/login", fleet.LoginRequest{Email: u.Email, Password: oldUserRawPwd}, http.StatusUnauthorized, &loginResp) // require a password reset var reqResetResp requirePasswordResetResponse @@ -10067,7 +10067,7 @@ func (s *integrationTestSuite) TestLogLoginAttempts() { // Login with invalid passwordm, should fail. res := s.DoRawNoAuth("POST", "/api/latest/fleet/login", - jsonMustMarshal(t, contract.LoginRequest{Email: u.Email, Password: test.GoodPassword2}), + jsonMustMarshal(t, fleet.LoginRequest{Email: u.Email, Password: test.GoodPassword2}), http.StatusUnauthorized, ) res.Body.Close() @@ -10090,7 +10090,7 @@ func (s *integrationTestSuite) TestLogLoginAttempts() { // login with good password, should succeed res = s.DoRawNoAuth("POST", "/api/latest/fleet/login", - jsonMustMarshal(t, contract.LoginRequest{ + jsonMustMarshal(t, fleet.LoginRequest{ Email: u.Email, Password: test.GoodPassword, }), http.StatusOK, @@ -10256,12 +10256,12 @@ func (s *integrationTestSuite) TestPasswordReset() { res.Body.Close() // login with the old password, should not succeed - res = s.DoRawNoAuth("POST", "/api/latest/fleet/login", jsonMustMarshal(t, contract.LoginRequest{Email: u.Email, Password: userRawPwd}), + res = s.DoRawNoAuth("POST", "/api/latest/fleet/login", jsonMustMarshal(t, fleet.LoginRequest{Email: u.Email, Password: userRawPwd}), http.StatusUnauthorized) res.Body.Close() // login with the new password, should succeed - res = s.DoRawNoAuth("POST", "/api/latest/fleet/login", jsonMustMarshal(t, contract.LoginRequest{Email: u.Email, Password: userNewPwd}), + res = s.DoRawNoAuth("POST", "/api/latest/fleet/login", jsonMustMarshal(t, fleet.LoginRequest{Email: u.Email, Password: userNewPwd}), http.StatusOK) res.Body.Close() } @@ -10360,8 +10360,8 @@ func (s *integrationTestSuite) TestModifyUser() { }, http.StatusUnprocessableEntity, &modResp) // login as the user, with the last password successfully set (to confirm it is the current one) - var loginResp loginResponse - resp := s.DoRawNoAuth("POST", "/api/latest/fleet/login", jsonMustMarshal(t, contract.LoginRequest{ + var loginResp fleet.LoginResponse + resp := s.DoRawNoAuth("POST", "/api/latest/fleet/login", jsonMustMarshal(t, fleet.LoginRequest{ Email: u.Email, // all email changes made are still pending, never confirmed Password: newRawPwd, }), http.StatusOK) diff --git a/server/service/integration_logger_test.go b/server/service/integration_logger_test.go index 22b58b4532..c0568e0c76 100644 --- a/server/service/integration_logger_test.go +++ b/server/service/integration_logger_test.go @@ -117,19 +117,19 @@ func (s *integrationLoggerTestSuite) TestLoggerLogin() { } testCases := []struct { - loginRequest contract.LoginRequest + loginRequest fleet.LoginRequest expectedStatus int expectedLevel slog.Level expectedAttrs []expectedAttr }{ { - loginRequest: contract.LoginRequest{Email: testUsers["admin1"].Email, Password: testUsers["admin1"].PlaintextPassword}, + loginRequest: fleet.LoginRequest{Email: testUsers["admin1"].Email, Password: testUsers["admin1"].PlaintextPassword}, expectedStatus: http.StatusOK, expectedLevel: slog.LevelInfo, expectedAttrs: []expectedAttr{{"email", testUsers["admin1"].Email}}, }, { - loginRequest: contract.LoginRequest{Email: testUsers["admin1"].Email, Password: "n074v411dp455w02d"}, + loginRequest: fleet.LoginRequest{Email: testUsers["admin1"].Email, Password: "n074v411dp455w02d"}, expectedStatus: http.StatusUnauthorized, expectedLevel: slog.LevelInfo, expectedAttrs: []expectedAttr{ @@ -138,7 +138,7 @@ func (s *integrationLoggerTestSuite) TestLoggerLogin() { }, }, { - loginRequest: contract.LoginRequest{Email: "h4x0r@3x4mp13.c0m", Password: "n074v411dp455w02d"}, + loginRequest: fleet.LoginRequest{Email: "h4x0r@3x4mp13.c0m", Password: "n074v411dp455w02d"}, expectedStatus: http.StatusUnauthorized, expectedLevel: slog.LevelInfo, expectedAttrs: []expectedAttr{ @@ -147,7 +147,7 @@ func (s *integrationLoggerTestSuite) TestLoggerLogin() { }, }, } - var resp loginResponse + var resp fleet.LoginResponse for _, tt := range testCases { s.DoJSON("POST", "/api/latest/fleet/login", tt.loginRequest, tt.expectedStatus, &resp) diff --git a/server/service/sessions.go b/server/service/sessions.go index e9d5ca10ae..fbeee1acb8 100644 --- a/server/service/sessions.go +++ b/server/service/sessions.go @@ -21,7 +21,6 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mail" "github.com/fleetdm/fleet/v4/server/platform/endpointer" - "github.com/fleetdm/fleet/v4/server/service/contract" "github.com/fleetdm/fleet/v4/server/sso" ) @@ -116,16 +115,6 @@ func (svc *Service) DeleteSession(ctx context.Context, id uint) error { // Login //////////////////////////////////////////////////////////////////////////////// -type loginResponse struct { - User *fleet.User `json:"user,omitempty"` - AvailableTeams []*fleet.TeamSummary `json:"available_teams" renameto:"available_fleets"` - Token string `json:"token,omitempty"` - TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"` - Err error `json:"error,omitempty"` -} - -func (r loginResponse) Error() error { return r.Err } - type loginMfaResponse struct { Message string `json:"message"` Err error `json:"error,omitempty"` @@ -136,7 +125,7 @@ func (r loginMfaResponse) Status() int { return http.StatusAccepted } func (r loginMfaResponse) Error() error { return r.Err } func loginEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { - req := request.(*contract.LoginRequest) + req := request.(*fleet.LoginRequest) req.Email = strings.ToLower(req.Email) user, session, err := svc.Login(ctx, req.Email, req.Password, req.SupportsEmailVerification) @@ -145,7 +134,7 @@ func loginEndpoint(ctx context.Context, request interface{}, svc fleet.Service) return loginMfaResponse{Message: "We sent an email to you. Please click the magic link in the email to sign in."}, nil } - return loginResponse{Err: err}, nil + return fleet.LoginResponse{Err: err}, nil } // Add viewer to context to allow access to service teams for list of available teams. ctx = viewer.NewContext(ctx, viewer.Viewer{ @@ -157,7 +146,7 @@ func loginEndpoint(ctx context.Context, request interface{}, svc fleet.Service) if errors.Is(err, fleet.ErrMissingLicense) { availableTeams = []*fleet.TeamSummary{} } else { - return loginResponse{Err: err}, nil + return fleet.LoginResponse{Err: err}, nil } } @@ -168,7 +157,7 @@ func loginEndpoint(ctx context.Context, request interface{}, svc fleet.Service) tokenExpiresAt = &expiresAt } - return loginResponse{ + return fleet.LoginResponse{ User: user, AvailableTeams: availableTeams, Token: session.Key, @@ -284,7 +273,7 @@ func sessionCreateEndpoint(ctx context.Context, request interface{}, svc fleet.S req := request.(*sessionCreateRequest) session, user, err := svc.CompleteMFA(ctx, req.Token) if err != nil { - return loginResponse{Err: err}, nil + return fleet.LoginResponse{Err: err}, nil } // Add viewer to context to allow access to service teams for list of available teams. ctx = viewer.NewContext(ctx, viewer.Viewer{ @@ -296,7 +285,7 @@ func sessionCreateEndpoint(ctx context.Context, request interface{}, svc fleet.S if errors.Is(err, fleet.ErrMissingLicense) { availableTeams = []*fleet.TeamSummary{} } else { - return loginResponse{Err: err}, nil + return fleet.LoginResponse{Err: err}, nil } } @@ -307,7 +296,7 @@ func sessionCreateEndpoint(ctx context.Context, request interface{}, svc fleet.S tokenExpiresAt = &expiresAt } - return loginResponse{ + return fleet.LoginResponse{ User: user, AvailableTeams: availableTeams, Token: session.Key, @@ -344,18 +333,12 @@ func (svc *Service) CompleteMFA(ctx context.Context, token string) (*fleet.Sessi // Logout //////////////////////////////////////////////////////////////////////////////// -type logoutResponse struct { - Err error `json:"error,omitempty"` -} - -func (r logoutResponse) Error() error { return r.Err } - func logoutEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { err := svc.Logout(ctx) if err != nil { - return logoutResponse{Err: err}, nil + return fleet.LogoutResponse{Err: err}, nil } - return logoutResponse{}, nil + return fleet.LogoutResponse{}, nil } func (svc *Service) Logout(ctx context.Context) error { diff --git a/server/service/svctest/users.go b/server/service/svctest/users.go index dc1d922a8d..0304bdd318 100644 --- a/server/service/svctest/users.go +++ b/server/service/svctest/users.go @@ -16,7 +16,6 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service" - "github.com/fleetdm/fleet/v4/server/service/contract" "github.com/fleetdm/fleet/v4/server/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -79,7 +78,7 @@ func createTestUsers(t *testing.T, ds fleet.Datastore) map[string]fleet.User { // GetToken posts to /api/latest/fleet/login and returns the auth token. It // fails the test on any error. func GetToken(t *testing.T, email string, password string, serverURL string) string { - params := contract.LoginRequest{ + params := fleet.LoginRequest{ Email: email, Password: password, } diff --git a/server/service/testing_client_test.go b/server/service/testing_client_test.go index 594379a66a..03404198f4 100644 --- a/server/service/testing_client_test.go +++ b/server/service/testing_client_test.go @@ -29,7 +29,6 @@ import ( "github.com/fleetdm/fleet/v4/server/live_query/live_query_mock" common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" "github.com/fleetdm/fleet/v4/server/pubsub" - "github.com/fleetdm/fleet/v4/server/service/contract" "github.com/fleetdm/fleet/v4/server/test" fleet_httptest "github.com/fleetdm/fleet/v4/server/test/httptest" "github.com/ghodss/yaml" @@ -391,7 +390,7 @@ func (ts *withServer) getTestToken(email string, password string) string { } func GetToken(t *testing.T, email string, password string, serverURL string) string { - params := contract.LoginRequest{ + params := fleet.LoginRequest{ Email: email, Password: password, }