diff --git a/server/src/main/java/com/objectcomputing/checkins/security/ImpersonationController.java b/server/src/main/java/com/objectcomputing/checkins/security/ImpersonationController.java new file mode 100644 index 0000000000..31424b97af --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/security/ImpersonationController.java @@ -0,0 +1,149 @@ +package com.objectcomputing.checkins.security; + +import com.objectcomputing.checkins.Environments; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.memberprofile.currentuser.CurrentUserServices; +import com.objectcomputing.checkins.services.permissions.Permission; +import com.objectcomputing.checkins.services.permissions.RequiredPermission; +import io.micronaut.context.env.Environment; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.cookie.SameSite; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.http.netty.cookies.NettyCookie; +import io.micronaut.security.utils.SecurityService; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.event.ApplicationEventPublisher; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Consumes; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; +import io.micronaut.scheduling.TaskExecutors; +import io.micronaut.scheduling.annotation.ExecuteOn; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.authentication.AuthenticationResponse; +import io.micronaut.security.authentication.Authenticator; +import io.micronaut.security.authentication.UsernamePasswordCredentials; +import io.micronaut.security.event.LoginFailedEvent; +import io.micronaut.security.event.LoginSuccessfulEvent; +import io.micronaut.security.handlers.LoginHandler; +import io.micronaut.security.rules.SecurityRule; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.HashSet; +import java.util.Set; +import java.net.URI; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Requires(env = {Environments.LOCAL, Environment.DEVELOPMENT}) +@Controller("/impersonation") +@ExecuteOn(TaskExecutors.BLOCKING) +@Secured(SecurityRule.IS_AUTHENTICATED) +public class ImpersonationController { + public static final String JWT = "JWT"; + public static final String originalJWT = "OJWT"; + private static final Logger LOG = LoggerFactory.getLogger(ImpersonationController.class); + protected final Authenticator authenticator; + protected final LoginHandler loginHandler; + protected final ApplicationEventPublisher eventPublisher; + private final CurrentUserServices currentUserServices; + private final SecurityService securityService; + + /** + * @param authenticator {@link Authenticator} collaborator + * @param loginHandler A collaborator which helps to build HTTP response depending on success or failure. + * @param eventPublisher The application event publisher + * @param currentUserServices Current User services + * @param securityService The Security Service + */ + public ImpersonationController(Authenticator authenticator, + LoginHandler loginHandler, + ApplicationEventPublisher eventPublisher, + CurrentUserServices currentUserServices, + SecurityService securityService) { + this.authenticator = authenticator; + this.loginHandler = loginHandler; + this.eventPublisher = eventPublisher; + this.currentUserServices = currentUserServices; + this.securityService = securityService; + } + + @Consumes({MediaType.APPLICATION_FORM_URLENCODED, MediaType.APPLICATION_JSON}) + @Post("/begin") + @RequiredPermission(Permission.CAN_IMPERSONATE_MEMBERS) + public Mono auth(HttpRequest request, String email) { + if (securityService != null) { + Optional auth = securityService.getAuthentication(); + if (auth.isPresent() && auth.get().getAttributes().get("email") != null) { + final Cookie jwt = request.getCookies().get(JWT); + if (jwt == null) { + // The user is required to be logged in. If this is null, + // we are in an impossible state! + LOG.error("Unable to locate the JWT"); + } else { + UsernamePasswordCredentials usernamePasswordCredentials = new UsernamePasswordCredentials(email, ""); + Flux authenticationResponseFlux = + Flux.from(authenticator.authenticate(request, usernamePasswordCredentials)); + return authenticationResponseFlux.map(authenticationResponse -> { + if (authenticationResponse.isAuthenticated() && authenticationResponse.getAuthentication().isPresent()) { + Authentication authentication = authenticationResponse.getAuthentication().get(); + // Get member profile by work email + MemberProfile memberProfile = currentUserServices.findOrSaveUser("", "", email); + String firstName = memberProfile.getFirstName() != null ? memberProfile.getFirstName() : ""; + String lastName = memberProfile.getLastName() != null ? memberProfile.getLastName() : ""; + + Map newAttributes = new HashMap<>(authentication.getAttributes()); + newAttributes.put("email", memberProfile.getWorkEmail()); + newAttributes.put("name", firstName + ' ' + lastName); + newAttributes.put("picture", ""); + Authentication updatedAuth = Authentication.build(authentication.getName(), authentication.getRoles(), newAttributes); + + eventPublisher.publishEvent(new LoginSuccessfulEvent(updatedAuth, null, Locale.getDefault())); + // Store the old JWT to allow the user to revert the impersonation. + return ((MutableHttpResponse)loginHandler.loginSuccess(updatedAuth, request)).cookie( + new NettyCookie(originalJWT, jwt.getValue()).path("/").sameSite(SameSite.Strict) + .maxAge(jwt.getMaxAge())); + } else { + eventPublisher.publishEvent(new LoginFailedEvent(authenticationResponse, null, null, Locale.getDefault())); + return loginHandler.loginFailed(authenticationResponse, request); + } + }).single(Mono.just(HttpResponse.unauthorized())); + } + } else { + LOG.error("Attempted impersonation without authentication."); + } + } + return Mono.just(HttpResponse.unauthorized()); + } + + @Produces(MediaType.TEXT_HTML) + @Get("/end") + public HttpResponse revert(HttpRequest request) { + final Cookie ojwt = request.getCookies().get(originalJWT); + if (ojwt == null) { + return HttpResponse.unauthorized(); + } else { + // Swap the OJWT back to the JWT and remove the original JWT + Set cookies = new HashSet(); + cookies.add(new NettyCookie(JWT, ojwt.getValue()).path("/") + .sameSite(SameSite.Strict) + .maxAge(ojwt.getMaxAge()).httpOnly()); + cookies.add(new NettyCookie(originalJWT, "").path("/").maxAge(0)); + + // Redirect to "/" while setting the cookies. + return HttpResponse.temporaryRedirect(URI.create("/")) + .cookies(cookies); + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/security/LocalLoginController.java b/server/src/main/java/com/objectcomputing/checkins/security/LocalLoginController.java index 3d6cbf5315..c9b0509113 100644 --- a/server/src/main/java/com/objectcomputing/checkins/security/LocalLoginController.java +++ b/server/src/main/java/com/objectcomputing/checkins/security/LocalLoginController.java @@ -3,6 +3,9 @@ import com.objectcomputing.checkins.Environments; import com.objectcomputing.checkins.services.memberprofile.MemberProfile; import com.objectcomputing.checkins.services.memberprofile.currentuser.CurrentUserServices; +import com.objectcomputing.checkins.security.ImpersonationController; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.netty.cookies.NettyCookie; import io.micronaut.context.annotation.Requires; import io.micronaut.context.event.ApplicationEventPublisher; import io.micronaut.http.HttpRequest; @@ -88,7 +91,10 @@ public Mono auth(HttpRequest request, String email, String role) { Authentication updatedAuth = Authentication.build(authentication.getName(), authentication.getRoles(), newAttributes); eventPublisher.publishEvent(new LoginSuccessfulEvent(updatedAuth, null, Locale.getDefault())); - return loginHandler.loginSuccess(updatedAuth, request); + + // Remove the original JWT on login. + return ((MutableHttpResponse)loginHandler.loginSuccess(updatedAuth, request)) + .cookie(new NettyCookie(ImpersonationController.originalJWT, "").path("/").maxAge(0)); } else { eventPublisher.publishEvent(new LoginFailedEvent(authenticationResponse, null, null, Locale.getDefault())); return loginHandler.loginFailed(authenticationResponse, request); diff --git a/server/src/main/java/com/objectcomputing/checkins/services/permissions/Permission.java b/server/src/main/java/com/objectcomputing/checkins/services/permissions/Permission.java index c375d96b9d..2942074820 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/permissions/Permission.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/permissions/Permission.java @@ -15,6 +15,7 @@ public enum Permission { CAN_VIEW_FEEDBACK_ANSWER("View feedback answers", "Feedback"), CAN_DELETE_ORGANIZATION_MEMBERS("Delete organization members", "User Management"), CAN_CREATE_ORGANIZATION_MEMBERS("Create organization members", "User Management"), + CAN_IMPERSONATE_MEMBERS("Impersonate organization members", "User Management"), CAN_VIEW_ROLE_PERMISSIONS("View role permissions", "Security"), CAN_ASSIGN_ROLE_PERMISSIONS("Assign role permissions", "Security"), CAN_VIEW_PERMISSIONS("View all permissions", "Security"), diff --git a/server/src/main/resources/db/dev/R__Load_testing_data.sql b/server/src/main/resources/db/dev/R__Load_testing_data.sql index f1d4c48caa..534f73bf49 100644 --- a/server/src/main/resources/db/dev/R__Load_testing_data.sql +++ b/server/src/main/resources/db/dev/R__Load_testing_data.sql @@ -850,6 +850,11 @@ insert into role_permissions values ('e8a4fff8-e984-4e59-be84-a713c9fa8d23', 'CAN_CREATE_KUDOS'); +insert into role_permissions + (roleid, permission) +values + ('e8a4fff8-e984-4e59-be84-a713c9fa8d23', 'CAN_IMPERSONATE_MEMBERS'); + -- PDL Permissions insert into role_permissions (roleid, permission) diff --git a/server/src/test/java/com/objectcomputing/checkins/security/ImpersonationControllerTest.java b/server/src/test/java/com/objectcomputing/checkins/security/ImpersonationControllerTest.java new file mode 100644 index 0000000000..cc8375bca5 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/security/ImpersonationControllerTest.java @@ -0,0 +1,138 @@ +package com.objectcomputing.checkins.security; + +import com.objectcomputing.checkins.Environments; +import com.objectcomputing.checkins.services.TestContainersSuite; +import com.objectcomputing.checkins.services.fixture.MemberProfileFixture; +import com.objectcomputing.checkins.services.fixture.RoleFixture; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.role.RoleType; +import com.objectcomputing.checkins.security.ImpersonationController; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.MutableHttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; + +import jakarta.inject.Inject; +import org.reactivestreams.Publisher; +import reactor.test.StepVerifier; + +import java.util.Map; +import java.util.Set; +import java.util.Iterator; +import org.json.JSONObject; + +import static com.objectcomputing.checkins.services.role.RoleType.Constants.ADMIN_ROLE; +import static com.objectcomputing.checkins.services.role.RoleType.Constants.MEMBER_ROLE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest(environments = {Environments.LOCAL, Environments.LOCALTEST}, transactional = false) +class ImpersonationControllerTest extends TestContainersSuite implements MemberProfileFixture, RoleFixture { + + @Client("/impersonation") + @Inject + HttpClient client; + + private MemberProfile nonAdmin; + private MemberProfile admin; + private String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.eyJjb21wYW55IjoiRnV0dXJlRWQiLCJzdWIiOjEsImlzcyI6Imh0dHA6XC9cL2Z1dHVyZWVkLmRldlwvYXBpXC92MVwvc3R1ZGVudFwvbG9naW5cL3VzZXJuYW1lIiwiaWF0IjoiMTQyNzQyNjc3MSIsImV4cCI6IjE0Mjc0MzAzNzEiLCJuYmYiOiIxNDI3NDI2NzcxIiwianRpIjoiNmFlZDQ3MGFiOGMxYTk0MmE0MTViYTAwOTBlMTFlZTUifQ.MmM2YTUwMjEzYTE0OGNhNjk5Y2Y2MjEwZDdkN2Y1OTQ2NWVhZTdmYmI4OTA5YmM1Y2QwYTMzZjUwNTgwY2Y0MQ"; + + @BeforeEach + void setUp() { + createAndAssignRoles(); + + nonAdmin = createADefaultMemberProfile(); + + admin = createASecondDefaultMemberProfile(); + assignAdminRole(admin); + } + + @Test + void testPostBeginEnd() { + HttpRequest> request = + HttpRequest.POST("/begin", + Map.of("email", nonAdmin.getWorkEmail())) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .basicAuth(admin.getWorkEmail(), ADMIN_ROLE); + ((MutableHttpRequest)request).cookie( + Cookie.of(ImpersonationController.JWT, jwt)); + Publisher response = client.retrieve(request); + assertNotNull(response); + final StringBuilder json = new StringBuilder(); + StepVerifier.create(response) + .thenConsumeWhile(resp -> { + assertTrue(resp.contains("\"username\":\"" + + nonAdmin.getWorkEmail())); + assertTrue(!resp.contains(jwt)); + json.append(resp); + return true; + }) + .expectComplete() + .verify(); + + JSONObject jsonObject = new JSONObject(json.toString()); + MutableHttpRequest next = HttpRequest.GET("/end") + .basicAuth(nonAdmin.getWorkEmail(), MEMBER_ROLE); + next.cookies( + Set.of(Cookie.of(ImpersonationController.originalJWT, jwt), + Cookie.of(ImpersonationController.JWT, + jsonObject.get("access_token").toString()))); + response = client.retrieve(next); + assertNotNull(response); + // This just needs to complete in order to verify that it has succeeded. + StepVerifier.create(response) + .thenConsumeWhile(resp -> { + return true; + }) + .expectComplete() + .verify(); + } + + @Test + void testGetEndNoOJWT() { + MutableHttpRequest request = HttpRequest.GET("/end") + .basicAuth(nonAdmin.getWorkEmail(), MEMBER_ROLE); + HttpClientResponseException response = + assertThrows(HttpClientResponseException.class, + () -> client.toBlocking().retrieve(request)); + assertNotNull(response); + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatus()); + } + + @Test + void testPostUnauthorizedBegin() { + HttpRequest> request = + HttpRequest.POST("/begin", + Map.of("email", admin.getWorkEmail())) + .contentType(MediaType.APPLICATION_FORM_URLENCODED); + HttpClientResponseException response = + assertThrows(HttpClientResponseException.class, + () -> client.toBlocking().retrieve(request)); + assertNotNull(response); + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatus()); + assertEquals("Unauthorized", response.getMessage()); + } + + @Test + void testGetUnauthorizedEnd() { + HttpRequest> request = + HttpRequest.GET("/end"); + HttpClientResponseException response = + assertThrows(HttpClientResponseException.class, + () -> client.toBlocking().retrieve(request)); + assertNotNull(response); + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatus()); + assertEquals("Unauthorized", response.getMessage()); + } +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/fixture/PermissionFixture.java b/server/src/test/java/com/objectcomputing/checkins/services/fixture/PermissionFixture.java index b9da13fb82..e8e219f637 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/fixture/PermissionFixture.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/fixture/PermissionFixture.java @@ -95,7 +95,8 @@ public interface PermissionFixture extends RolePermissionFixture { Permission.CAN_ADMINISTER_VOLUNTEERING_EVENTS, Permission.CAN_ADMINISTER_DOCUMENTATION, Permission.CAN_ADMINISTER_KUDOS, - Permission.CAN_CREATE_KUDOS + Permission.CAN_CREATE_KUDOS, + Permission.CAN_IMPERSONATE_MEMBERS ); default void setPermissionsForAdmin(UUID roleID) { diff --git a/web-ui/src/components/member-directory/AdminMemberCard.jsx b/web-ui/src/components/member-directory/AdminMemberCard.jsx index d57b5afe16..0d19a57aba 100644 --- a/web-ui/src/components/member-directory/AdminMemberCard.jsx +++ b/web-ui/src/components/member-directory/AdminMemberCard.jsx @@ -6,7 +6,7 @@ import MemberModal from './MemberModal'; import { AppContext } from '../../context/AppContext'; import { DELETE_MEMBER_PROFILE, UPDATE_MEMBER_PROFILES, UPDATE_TOAST } from '../../context/actions'; import { selectProfileMap } from '../../context/selectors'; -import { getAvatarURL } from '../../api/api.js'; +import { getAvatarURL, resolve } from '../../api/api.js'; import Avatar from '@mui/material/Avatar'; import PriorityHighIcon from '@mui/icons-material/PriorityHigh'; @@ -73,16 +73,45 @@ const AdminMemberCard = ({ member, index }) => { const handleClose = () => setOpen(false); const handleCloseDeleteConfirmation = () => setOpenDelete(false); - const options = isAdmin ? ['Edit', 'Delete'] : ['Edit']; + const options = () => { + let entries = ['Edit']; + if (isAdmin) { + entries.push('Delete'); + // If we have not already impersonated a user, we can provide that option. + if (document.cookie.indexOf("OJWT=") == -1) { + entries.push('Impersonate'); + } + } + return entries; + } const handleAction = (e, index) => { if (index === 0) { handleOpen(); } else if (index === 1) { handleOpenDeleteConfirmation(); + } else if (index === 2) { + handleImpersonate(); } }; + const handleImpersonate = async () => { + // "log in" as the chosen user with the default role. + const res = await resolve({ + method: 'POST', + url: '/impersonation/begin', + headers: { + 'X-CSRF-Header': csrf, + Accept: 'application/json', + 'Content-Type': 'application/json;charset=UTF-8' + }, + data: { email: workEmail } + }); + + // If that was successful, take the user back to the main page. + if (!res.error) window.location.href = "/"; + } + const handleDeleteMember = async () => { let res = await deleteMember(memberId, csrf); if (res && res.payload && res.payload.status === 200) { @@ -184,7 +213,7 @@ const AdminMemberCard = ({ member, index }) => { { + return document.cookie.indexOf("OJWT=") != -1; + } + return (
@@ -89,6 +94,8 @@ export default function HomePage() { )}
+ {checkForImpersonation() && + }
); }