Skip to content

Commit

Permalink
Merge pull request #2570 from objectcomputing/feature-2569/impersonat…
Browse files Browse the repository at this point in the history
…e-user

Feature 2569/impersonate user
  • Loading branch information
mkimberlin authored Aug 13, 2024
2 parents 6d8cdb9 + 5c987b1 commit 3827323
Show file tree
Hide file tree
Showing 9 changed files with 347 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -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<Object> auth(HttpRequest<?> request, String email) {
if (securityService != null) {
Optional<Authentication> 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<AuthenticationResponse> 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<String, Object> 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<Object> 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<Cookie> cookies = new HashSet<Cookie>();
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,7 +91,10 @@ public Mono<Object> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
5 changes: 5 additions & 0 deletions server/src/main/resources/db/dev/R__Load_testing_data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Map<String, String>> 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<String> 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<Object> 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<Object> 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<Map<String, String>> 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<Map<String, String>> request =
HttpRequest.GET("/end");
HttpClientResponseException response =
assertThrows(HttpClientResponseException.class,
() -> client.toBlocking().retrieve(request));
assertNotNull(response);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatus());
assertEquals("Unauthorized", response.getMessage());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit 3827323

Please sign in to comment.