-
Notifications
You must be signed in to change notification settings - Fork 6
Feature 2569/impersonate user #2570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
38ab7f5
be769de
59ac55c
3c4ccc6
2af9ff2
5c987b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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."); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can still do these though...just |
||
| } | ||
| } | ||
| return Mono.just(HttpResponse.unauthorized()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
| @@ -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()); | ||
| } | ||
| } |
There was a problem hiding this comment.
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
Monousage here. It shouldn't be necessary any longer (there are only a few endpoints that use these still). I think you want anHttpResponse<Void>here for your return object instead...or maybe aHttpResponse<?>. Then you can use methods likeHttpResponse.serverError()andHttpResponse.ok()to construct a response with the right response code. Those methods returnMutableHttpResponseinstances. 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.There was a problem hiding this comment.
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?There was a problem hiding this comment.
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.