Skip to content

Commit

Permalink
Allow App Context Specific Invitations
Browse files Browse the repository at this point in the history
- Add support for wildcards in redirect uris

[#100343056] https://www.pivotaltracker.com/story/show/100343056

Signed-off-by: Jonathan Lo <jlo@us.ibm.com>
  • Loading branch information
Chris Dutra committed Sep 3, 2015
1 parent 0c6350c commit a1c3829
Show file tree
Hide file tree
Showing 10 changed files with 143 additions and 108 deletions.
Expand Up @@ -10,6 +10,7 @@
import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning;
import org.cloudfoundry.identity.uaa.scim.exception.ScimResourceAlreadyExistsException; import org.cloudfoundry.identity.uaa.scim.exception.ScimResourceAlreadyExistsException;
import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.JsonUtils;
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.common.util.RandomValueStringGenerator; import org.springframework.security.oauth2.common.util.RandomValueStringGenerator;
import org.springframework.security.oauth2.provider.ClientDetails; import org.springframework.security.oauth2.provider.ClientDetails;
Expand All @@ -22,22 +23,21 @@
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;


@Service @Service
public class EmailInvitationsService implements InvitationsService { public class EmailInvitationsService implements InvitationsService {
private final Log logger = LogFactory.getLog(getClass()); private final Log logger = LogFactory.getLog(getClass());


public static final String INVITATION_REDIRECT_URL = "invitation_redirect_url";
public static final int INVITATION_EXPIRY_DAYS = 365; public static final int INVITATION_EXPIRY_DAYS = 365;


private final SpringTemplateEngine templateEngine; private final SpringTemplateEngine templateEngine;
private final MessageService messageService; private final MessageService messageService;


@Autowired @Autowired
private ScimUserProvisioning scimUserProvisioning; private ScimUserProvisioning scimUserProvisioning;


private String brand; private String brand;


public EmailInvitationsService(SpringTemplateEngine templateEngine, MessageService messageService, String brand) { public EmailInvitationsService(SpringTemplateEngine templateEngine, MessageService messageService, String brand) {
Expand Down Expand Up @@ -84,12 +84,13 @@ private String getEmailHtml(String currentUser, String code) {
} }


@Override @Override
public void inviteUser(String email, String currentUser, String redirectUri) { public void inviteUser(String email, String currentUser, String clientId, String redirectUri) {
try { try {
ScimUser user = accountCreationService.createUser(email, new RandomValueStringGenerator().generate()); ScimUser user = accountCreationService.createUser(email, new RandomValueStringGenerator().generate());
Map<String,String> data = new HashMap<>(); Map<String,String> data = new HashMap<>();
data.put("user_id", user.getId()); data.put("user_id", user.getId());
data.put("email", email); data.put("email", email);
data.put("client_id", clientId);
data.put("redirect_uri", redirectUri); data.put("redirect_uri", redirectUri);
String code = expiringCodeService.generateCode(data, INVITATION_EXPIRY_DAYS, TimeUnit.DAYS); String code = expiringCodeService.generateCode(data, INVITATION_EXPIRY_DAYS, TimeUnit.DAYS);
sendInvitationEmail(email, currentUser, code); sendInvitationEmail(email, currentUser, code);
Expand All @@ -102,6 +103,7 @@ public void inviteUser(String email, String currentUser, String redirectUri) {
Map<String,String> data = new HashMap<>(); Map<String,String> data = new HashMap<>();
data.put("user_id", existingUserResponse.getUserId()); data.put("user_id", existingUserResponse.getUserId());
data.put("email", email); data.put("email", email);
data.put("client_id", clientId);
data.put("redirect_uri", redirectUri); data.put("redirect_uri", redirectUri);
String code = expiringCodeService.generateCode(data, INVITATION_EXPIRY_DAYS, TimeUnit.DAYS); String code = expiringCodeService.generateCode(data, INVITATION_EXPIRY_DAYS, TimeUnit.DAYS);
sendInvitationEmail(email, currentUser, code); sendInvitationEmail(email, currentUser, code);
Expand All @@ -116,25 +118,26 @@ public void inviteUser(String email, String currentUser, String redirectUri) {
} }


@Override @Override
public String acceptInvitation(String userId, String email, String password, String clientId) { public String acceptInvitation(String userId, String email, String password, String clientId, String redirectUri) {
ScimUser user = scimUserProvisioning.retrieve(userId); ScimUser user = scimUserProvisioning.retrieve(userId);
scimUserProvisioning.verifyUser(user.getId(), user.getVersion()); scimUserProvisioning.verifyUser(user.getId(), user.getVersion());


PasswordChangeRequest request = new PasswordChangeRequest(); PasswordChangeRequest request = new PasswordChangeRequest();
request.setPassword(password); request.setPassword(password);

scimUserProvisioning.changePassword(userId, null, password); scimUserProvisioning.changePassword(userId, null, password);


String redirectLocation = null; String redirectLocation = "/home";
if (clientId != null && !clientId.equals("")) { if (!clientId.equals("")) {
try { try {
ClientDetails clientDetails = clientAdminEndpoints.getClientDetails(clientId); ClientDetails clientDetails = clientAdminEndpoints.getClientDetails(clientId);
redirectLocation = (String) clientDetails.getAdditionalInformation().get(INVITATION_REDIRECT_URL); Set<Pattern> wildcards = UaaStringUtils.constructWildcards(clientDetails.getRegisteredRedirectUri());
}catch (Exception x) { if (UaaStringUtils.matches(wildcards, redirectUri)) {
throw new RuntimeException(x); redirectLocation = redirectUri;
}
} catch (Exception x) {
return redirectLocation;
} }
} }

return redirectLocation; return redirectLocation;
} }
} }
Expand Up @@ -42,30 +42,35 @@ public InvitationsController(InvitationsService invitationsService) {
} }


@RequestMapping(value = "/new", method = GET) @RequestMapping(value = "/new", method = GET)
public String newInvitePage(Model model, @RequestParam(required = false, value = "redirect_uri") String redirectUri) { public String newInvitePage(Model model, @RequestParam(required = false, value = "client_id") String clientId,
@RequestParam(required = false, value = "redirect_uri") String redirectUri) {
model.addAttribute("client_id", clientId);
model.addAttribute("redirect_uri", redirectUri); model.addAttribute("redirect_uri", redirectUri);
return "invitations/new_invite"; return "invitations/new_invite";
} }




@RequestMapping(value = "/new.do", method = POST, params = {"email"}) @RequestMapping(value = "/new.do", method = POST, params = {"email"})
public String sendInvitationEmail(@Valid @ModelAttribute("email") ValidEmail email, BindingResult result, @RequestParam("redirect_uri") String redirectUri, Model model, HttpServletResponse response) { public String sendInvitationEmail(@Valid @ModelAttribute("email") ValidEmail email, BindingResult result,
@RequestParam(defaultValue = "", value = "client_id") String clientId,
@RequestParam(defaultValue = "", value = "redirect_uri") String redirectUri,
Model model, HttpServletResponse response) {
if (result.hasErrors()) { if (result.hasErrors()) {
return handleUnprocessableEntity(model, response, "error_message_code", "invalid_email", "invitations/new_invite"); return handleUnprocessableEntity(model, response, "error_message_code", "invalid_email", "invitations/new_invite");
} }


UaaPrincipal p = ((UaaPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal()); UaaPrincipal p = ((UaaPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal());
String currentUser = p.getName(); String currentUser = p.getName();
try { try {
invitationsService.inviteUser(email.getEmail(), currentUser, redirectUri); invitationsService.inviteUser(email.getEmail(), currentUser, clientId, redirectUri);
} catch (UaaException e) { } catch (UaaException e) {
return handleUnprocessableEntity(model, response, "error_message_code", "existing_user", "invitations/new_invite"); return handleUnprocessableEntity(model, response, "error_message_code", "existing_user", "invitations/new_invite");
} }
return "redirect:sent"; return "redirect:sent";
} }


@RequestMapping(value = "sent", method = GET) @RequestMapping(value = "sent", method = GET)
public String inviteSentPage(Model model) { public String inviteSentPage() {
return "invitations/invite_sent"; return "invitations/invite_sent";
} }


Expand All @@ -86,8 +91,8 @@ public String acceptInvitePage(@RequestParam String code, Model model, HttpServl
@RequestMapping(value = "/accept.do", method = POST) @RequestMapping(value = "/accept.do", method = POST)
public String acceptInvitation(@RequestParam("password") String password, public String acceptInvitation(@RequestParam("password") String password,
@RequestParam("password_confirmation") String passwordConfirmation, @RequestParam("password_confirmation") String passwordConfirmation,
@RequestParam("client_id") String clientId, @RequestParam(defaultValue = "", value = "client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri, @RequestParam(defaultValue = "", value = "redirect_uri") String redirectUri,
Model model, HttpServletResponse servletResponse) throws IOException { Model model, HttpServletResponse servletResponse) throws IOException {


PasswordConfirmationValidation validation = new PasswordConfirmationValidation(password, passwordConfirmation); PasswordConfirmationValidation validation = new PasswordConfirmationValidation(password, passwordConfirmation);
Expand All @@ -104,15 +109,9 @@ public String acceptInvitation(@RequestParam("password") String password,
model.addAttribute("email", principal.getEmail()); model.addAttribute("email", principal.getEmail());
return handleUnprocessableEntity(model, servletResponse, "error_message", e.getMessagesAsOneString(), "invitations/accept_invite"); return handleUnprocessableEntity(model, servletResponse, "error_message", e.getMessagesAsOneString(), "invitations/accept_invite");
} }
String redirectLocation = invitationsService.acceptInvitation(principal.getId(), principal.getEmail(), password, clientId);


if (!redirectUri.equals("")) { String redirectLocation = invitationsService.acceptInvitation(principal.getId(), principal.getEmail(), password, clientId, redirectUri);
return "redirect:" + redirectUri; return "redirect:" + redirectLocation;
}
if (redirectLocation != null) {
return "redirect:" + redirectLocation;
}
return "redirect:/home";
} }


private String handleUnprocessableEntity(Model model, HttpServletResponse response, String attributeKey, String attributeValue, String view) { private String handleUnprocessableEntity(Model model, HttpServletResponse response, String attributeKey, String attributeValue, String view) {
Expand Down
@@ -1,7 +1,7 @@
package org.cloudfoundry.identity.uaa.login; package org.cloudfoundry.identity.uaa.login;


public interface InvitationsService { public interface InvitationsService {
void inviteUser(String email, String currentUser, String redirectUri); void inviteUser(String email, String currentUser, String clientId, String redirectUri);


String acceptInvitation(String userId, String email, String password, String clientId); String acceptInvitation(String userId, String email, String password, String clientId, String redirectUri);
} }
Expand Up @@ -19,6 +19,7 @@ <h1>Send an invite</h1>
<p th:text="#{'new_invite.' + ${error_message_code}}">Error Message</p> <p th:text="#{'new_invite.' + ${error_message_code}}">Error Message</p>
</div> </div>
<input name="email" type="email" placeholder="Enter email" autofocus="autofocus" class="form-control"/> <input name="email" type="email" placeholder="Enter email" autofocus="autofocus" class="form-control"/>
<input name="client_id" type="hidden" th:value="${client_id}"/>
<input name="redirect_uri" type="hidden" th:value="${redirect_uri}"/> <input name="redirect_uri" type="hidden" th:value="${redirect_uri}"/>
<input type="submit" value="Send invite" class="island-button"/> <input type="submit" value="Send invite" class="island-button"/>
</form> </form>
Expand Down
Expand Up @@ -7,12 +7,13 @@
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat; import static org.junit.Assert.assertThat;
import static org.mockito.Matchers.anyInt; import static org.mockito.Matchers.anyInt;
import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.eq; import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;




Expand Down Expand Up @@ -102,10 +103,11 @@ public void testSendInviteEmail() throws Exception {
ArgumentCaptor<Map<String,String>> captor = ArgumentCaptor.forClass((Class)Map.class); ArgumentCaptor<Map<String,String>> captor = ArgumentCaptor.forClass((Class)Map.class);


when(expiringCodeService.generateCode(captor.capture(), anyInt(), eq(TimeUnit.DAYS))).thenReturn("the_secret_code"); when(expiringCodeService.generateCode(captor.capture(), anyInt(), eq(TimeUnit.DAYS))).thenReturn("the_secret_code");
emailInvitationsService.inviteUser("user@example.com", "current-user", "blah.example.com"); emailInvitationsService.inviteUser("user@example.com", "current-user", "client-id", "blah.example.com");


Map<String,String> data = captor.getValue(); Map<String,String> data = captor.getValue();
assertEquals("existing-user-id", data.get("user_id")); assertEquals("existing-user-id", data.get("user_id"));
assertEquals("client-id", data.get("client_id"));
assertEquals("blah.example.com", data.get("redirect_uri")); assertEquals("blah.example.com", data.get("redirect_uri"));


ArgumentCaptor<String> emailBodyArgument = ArgumentCaptor.forClass(String.class); ArgumentCaptor<String> emailBodyArgument = ArgumentCaptor.forClass(String.class);
Expand All @@ -121,14 +123,32 @@ public void testSendInviteEmail() throws Exception {
assertThat(emailBody, containsString("<a href=\"http://localhost/login/invitations/accept?code=the_secret_code\">Accept Invite</a>")); assertThat(emailBody, containsString("<a href=\"http://localhost/login/invitations/accept?code=the_secret_code\">Accept Invite</a>"));
assertThat(emailBody, not(containsString("Cloud Foundry"))); assertThat(emailBody, not(containsString("Cloud Foundry")));
} }


@Test
public void inviteUserWithoutClientIdOrRedirectUri() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setProtocol("http");
request.setContextPath("/login");
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));

ArgumentCaptor<Map<String,String>> captor = ArgumentCaptor.forClass((Class)Map.class);

when(expiringCodeService.generateCode(captor.capture(), anyInt(), eq(TimeUnit.DAYS))).thenReturn("the_secret_code");
emailInvitationsService.inviteUser("user@example.com", "current-user", "", "");

Map<String,String> data = captor.getValue();
assertEquals("existing-user-id", data.get("user_id"));
assertEquals("", data.get("client_id"));
assertEquals("", data.get("redirect_uri"));
}

@Test(expected = UaaException.class) @Test(expected = UaaException.class)
public void testSendInviteEmailToUserThatIsAlreadyVerified() throws Exception { public void testSendInviteEmailToUserThatIsAlreadyVerified() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.setProtocol("http"); request.setProtocol("http");
request.setContextPath("/login"); request.setContextPath("/login");


emailInvitationsService.inviteUser("alreadyverified@example.com", "current-user", ""); emailInvitationsService.inviteUser("alreadyverified@example.com", "current-user", "", "");
} }


@Test @Test
Expand All @@ -139,12 +159,10 @@ public void testSendInviteEmailToUnverifiedUser() throws Exception {
request.setContextPath("/login"); request.setContextPath("/login");
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));


byte[] errorResponse = "{\"error\":\"invalid_user\",\"message\":\"error message\",\"user_id\":\"existing-user-id\",\"verified\":false,\"active\":true}".getBytes();

ArgumentCaptor<Map<String,String>> captor = ArgumentCaptor.forClass((Class)Map.class); ArgumentCaptor<Map<String,String>> captor = ArgumentCaptor.forClass((Class)Map.class);


when(expiringCodeService.generateCode(captor.capture(), anyInt(), eq(TimeUnit.DAYS))).thenReturn("the_secret_code"); when(expiringCodeService.generateCode(captor.capture(), anyInt(), eq(TimeUnit.DAYS))).thenReturn("the_secret_code");
emailInvitationsService.inviteUser("existingunverified@example.com", "current-user", "blah.example.com"); emailInvitationsService.inviteUser("existingunverified@example.com", "current-user", "", "blah.example.com");


Map<String,String> data = captor.getValue(); Map<String,String> data = captor.getValue();
assertEquals("existing-user-id", data.get("user_id")); assertEquals("existing-user-id", data.get("user_id"));
Expand Down Expand Up @@ -172,10 +190,10 @@ public void testSendInviteEmailWithOSSBrand() throws Exception {
request.setContextPath("/login"); request.setContextPath("/login");
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));


ArgumentCaptor<Map<String,String>> captor = ArgumentCaptor.forClass((Class)Map.class); ArgumentCaptor<Map<String,String>> captor = ArgumentCaptor.forClass((Class) Map.class);


when(expiringCodeService.generateCode(captor.capture(), anyInt(), eq(TimeUnit.DAYS))).thenReturn("the_secret_code"); when(expiringCodeService.generateCode(captor.capture(), anyInt(), eq(TimeUnit.DAYS))).thenReturn("the_secret_code");
emailInvitationsService.inviteUser("user@example.com", "current-user", ""); emailInvitationsService.inviteUser("user@example.com", "current-user", "", "");


Map<String,String> data = captor.getValue(); Map<String,String> data = captor.getValue();
assertEquals("existing-user-id", data.get("user_id")); assertEquals("existing-user-id", data.get("user_id"));
Expand All @@ -195,29 +213,56 @@ public void testSendInviteEmailWithOSSBrand() throws Exception {
} }


@Test @Test
public void testAcceptInvitation() throws Exception { public void acceptInvitationNoClientId() throws Exception {
ScimUser user = new ScimUser("user-id-001", "user@example.com", "first", "last");
when(scimUserProvisioning.retrieve(eq("user-id-001"))).thenReturn(user);
String redirectLocation = emailInvitationsService.acceptInvitation("user-id-001", "user@example.com", "secret", "", "");


verify(scimUserProvisioning).verifyUser(user.getId(), user.getVersion());
verify(scimUserProvisioning).changePassword(user.getId(), null, "secret");
Mockito.verifyZeroInteractions(expiringCodeService);
assertEquals("/home", redirectLocation);
}


@Test
public void acceptInvitationWithClientNotFound() throws Exception {
ScimUser user = new ScimUser("user-id-001", "user@example.com", "first", "last"); ScimUser user = new ScimUser("user-id-001", "user@example.com", "first", "last");
BaseClientDetails clientDetails = new BaseClientDetails("app", null, null, null, null, "http://example.com/redirect");
clientDetails.addAdditionalInformation("invitation_redirect_url", "http://example.com/redirect");
when(scimUserProvisioning.retrieve(eq("user-id-001"))).thenReturn(user); when(scimUserProvisioning.retrieve(eq("user-id-001"))).thenReturn(user);
when(clientAdminEndpoints.getClientDetails(eq("app"))).thenReturn(clientDetails); doThrow(new Exception("Client not found")).when(clientAdminEndpoints).getClientDetails("client-not-found");
String redirectLocation = emailInvitationsService.acceptInvitation("user-id-001", "user@example.com", "secret", "app"); String redirectLocation = emailInvitationsService.acceptInvitation("user-id-001", "user@example.com", "secret", "client-not-found", "");


verify(scimUserProvisioning).verifyUser(user.getId(), user.getVersion());
verify(scimUserProvisioning).changePassword(user.getId(), null, "secret");
Mockito.verifyZeroInteractions(expiringCodeService); Mockito.verifyZeroInteractions(expiringCodeService);
assertEquals("http://example.com/redirect", redirectLocation); assertEquals("/home", redirectLocation);
} }


@Test @Test
public void testAcceptInvitationWithNoClientRedirect() throws Exception { public void acceptInvitationWithValidRedirectUri() throws Exception {
ScimUser user = new ScimUser("user-id-001", "user@example.com", "first", "last");
BaseClientDetails clientDetails = new BaseClientDetails("client-id", null, null, null, null, "http://example.com/*/");
when(scimUserProvisioning.retrieve(eq("user-id-001"))).thenReturn(user);
when(clientAdminEndpoints.getClientDetails("client-id")).thenReturn(clientDetails);
String redirectLocation = emailInvitationsService.acceptInvitation("user-id-001", "user@example.com", "secret", "client-id", "http://example.com/redirect/");


verify(scimUserProvisioning).verifyUser(user.getId(), user.getVersion());
verify(scimUserProvisioning).changePassword(user.getId(), null, "secret");
Mockito.verifyZeroInteractions(expiringCodeService);
assertEquals("http://example.com/redirect/", redirectLocation);
}

@Test
public void acceptInvitationWithInvalidRedirectUri() throws Exception {
ScimUser user = new ScimUser("user-id-001", "user@example.com", "first", "last"); ScimUser user = new ScimUser("user-id-001", "user@example.com", "first", "last");
BaseClientDetails clientDetails = new BaseClientDetails("client-id", null, null, null, null, "http://example.com/redirect");
when(scimUserProvisioning.retrieve(eq("user-id-001"))).thenReturn(user); when(scimUserProvisioning.retrieve(eq("user-id-001"))).thenReturn(user);
when(clientAdminEndpoints.getClientDetails("client-id")).thenReturn(clientDetails);
String redirectLocation = emailInvitationsService.acceptInvitation("user-id-001", "user@example.com", "secret", "client-id", "http://example.com/other/redirect");


String redirectLocation = emailInvitationsService.acceptInvitation("user-id-001", "user@example.com", "secret", ""); verify(scimUserProvisioning).verifyUser(user.getId(), user.getVersion());
verify(scimUserProvisioning).changePassword(user.getId(), null, "secret");
Mockito.verifyZeroInteractions(expiringCodeService); Mockito.verifyZeroInteractions(expiringCodeService);
assertNull(redirectLocation); assertEquals("/home", redirectLocation);
} }


@Configuration @Configuration
Expand Down

0 comments on commit a1c3829

Please sign in to comment.