-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2570 from objectcomputing/feature-2569/impersonat…
…e-user Feature 2569/impersonate user
- Loading branch information
Showing
9 changed files
with
347 additions
and
5 deletions.
There are no files selected for viewing
149 changes: 149 additions & 0 deletions
149
server/src/main/java/com/objectcomputing/checkins/security/ImpersonationController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
server/src/test/java/com/objectcomputing/checkins/security/ImpersonationControllerTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.