Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can get rid of the Mono usage here. It shouldn't be necessary any longer (there are only a few endpoints that use these still). I think you want an HttpResponse<Void> here for your return object instead...or maybe a HttpResponse<?>. Then you can use methods like HttpResponse.serverError() and HttpResponse.ok() to construct a response with the right response code. Those methods return MutableHttpResponse instances. So, you can do things like add cookies, etc. if needed.

At minimum it looks like you probably want to return HttpResponse.serverError() in the default cases toward the end.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Authenticator.authenticate() returns a Publisher. I'm thinking Mono was left in the LocalLoginController because of this. Or maybe I just don't know how to deal with the Publisher such that we can return an HttpResponse?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at this a little bit. Definitely not worth trying to change.

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.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpResponse.serverError() here...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can still do these though...just return Mono.just(HttpResponse.serverError());

}
}
return Mono.just(HttpResponse.unauthorized());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

@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