diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/UserResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/UserResource.java index e8de29db23..1901a53aa5 100755 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/UserResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/UserResource.java @@ -269,6 +269,30 @@ public interface UserResource { @Path("send-verify-email") void sendVerifyEmail(@QueryParam("client_id") String clientId); + @PUT + @Path("send-verify-email") + void sendVerifyEmail(@QueryParam("client_id") String clientId, @QueryParam("redirect_uri") String redirectUri); + + @PUT + @Path("send-verify-email") + void sendVerifyEmail(@QueryParam("lifespan") Integer lifespan); + + /** + * Send an email-verification email to the user + * + * An email contains a link the user can click to verify their email address. + * The redirectUri and clientId parameters are optional. The default for the + * redirect is the account client. The default for the lifespan is 12 hours. + * + * @param redirectUri Redirect uri + * @param clientId Client id + * @param lifespan Number of seconds after which the generated token expires + * @return + */ + @PUT + @Path("send-verify-email") + void sendVerifyEmail(@QueryParam("client_id") String clientId, @QueryParam("redirect_uri") String redirectUri, @QueryParam("lifespan") Integer lifespan); + @GET @Path("sessions") List getUserSessions(); diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionToken.java b/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionToken.java index 576536f9cf..2d8d4e044f 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionToken.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionToken.java @@ -29,6 +29,7 @@ public class VerifyEmailActionToken extends DefaultActionToken { public static final String TOKEN_TYPE = "verify-email"; private static final String JSON_FIELD_ORIGINAL_AUTHENTICATION_SESSION_ID = "oasid"; + private static final String JSON_FIELD_REDIRECT_URI = "reduri"; @JsonProperty(value = JSON_FIELD_ORIGINAL_AUTHENTICATION_SESSION_ID) private String originalAuthenticationSessionId; @@ -49,4 +50,18 @@ public class VerifyEmailActionToken extends DefaultActionToken { public void setCompoundOriginalAuthenticationSessionId(String originalAuthenticationSessionId) { this.originalAuthenticationSessionId = originalAuthenticationSessionId; } + + @JsonProperty(value = JSON_FIELD_REDIRECT_URI) + public String getRedirectUri() { + return (String) getOtherClaims().get(JSON_FIELD_REDIRECT_URI); + } + + @JsonProperty(value = JSON_FIELD_REDIRECT_URI) + public final void setRedirectUri(String redirectUri) { + if (redirectUri == null) { + getOtherClaims().remove(JSON_FIELD_REDIRECT_URI); + } else { + setOtherClaims(JSON_FIELD_REDIRECT_URI, redirectUri); + } + } } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionTokenHandler.java index 6db85aba78..1228e6ec7d 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionTokenHandler.java @@ -26,6 +26,8 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserModel.RequiredAction; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.services.Urls; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationSessionManager; @@ -107,6 +109,13 @@ public class VerifyEmailActionTokenHandler extends AbstractActionTokenHandler actions) { auth.users().requireManage(user); - if (user.getEmail() == null) { - throw ErrorResponse.error("User email missing", Status.BAD_REQUEST); - } - - if (!user.isEnabled()) { - throw ErrorResponse.error("User is disabled", Status.BAD_REQUEST); - } - - if (redirectUri != null && clientId == null) { - throw ErrorResponse.error("Client id missing", Status.BAD_REQUEST); - } - - if (clientId == null) { - clientId = Constants.ACCOUNT_MANAGEMENT_CLIENT_ID; - } + SendEmailParams result = verifySendEmailParams(redirectUri, clientId, lifespan); if (CollectionUtil.isNotEmpty(actions) && !RequiredActionsValidator.validRequiredActions(session, actions)) { throw ErrorResponse.error("Provided invalid required actions", Status.BAD_REQUEST); } - ClientModel client = realm.getClientByClientId(clientId); - if (client == null) { - logger.debugf("Client %s doesn't exist", clientId); - throw ErrorResponse.error("Client doesn't exist", Status.BAD_REQUEST); - } - if (!client.isEnabled()) { - logger.debugf("Client %s is not enabled", clientId); - throw ErrorResponse.error("Client is not enabled", Status.BAD_REQUEST); - } - - String redirect; - if (redirectUri != null) { - redirect = RedirectUtils.verifyRedirectUri(session, redirectUri, client); - if (redirect == null) { - throw ErrorResponse.error("Invalid redirect uri.", Status.BAD_REQUEST); - } - } - - if (lifespan == null) { - lifespan = realm.getActionTokenGeneratedByAdminLifespan(); - } - int expiration = Time.currentTime() + lifespan; - ExecuteActionsActionToken token = new ExecuteActionsActionToken(user.getId(), user.getEmail(), expiration, actions, redirectUri, clientId); + int expiration = Time.currentTime() + result.lifespan; + ExecuteActionsActionToken token = new ExecuteActionsActionToken(user.getId(), user.getEmail(), expiration, actions, result.redirectUri, result.clientId); try { UriBuilder builder = LoginActionsService.actionTokenProcessor(session.getContext().getUri()); @@ -912,7 +878,7 @@ public class UserResource { .setAttribute(Constants.TEMPLATE_ATTR_REQUIRED_ACTIONS, token.getRequiredActions()) .setRealm(realm) .setUser(user) - .sendExecuteActions(link, TimeUnit.SECONDS.toMinutes(lifespan)); + .sendExecuteActions(link, TimeUnit.SECONDS.toMinutes(result.lifespan)); //audit.user(user).detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, accessCode.getCodeId()).success(); @@ -934,6 +900,7 @@ public class UserResource { * * @param redirectUri Redirect uri * @param clientId Client id + * @param lifespan Number of seconds after which the generated token expires * @return */ @Path("send-verify-email") @@ -941,15 +908,39 @@ public class UserResource { @Consumes(MediaType.APPLICATION_JSON) @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS) @Operation( - summary = "Send an email-verification email to the user An email contains a link the user can click to verify their email address.", - description = "The redirectUri and clientId parameters are optional. The default for the redirect is the account client." + summary = "Send an email-verification email to the user An email contains a link the user can click to verify their email address.", + description = "The redirectUri, clientId and lifespan parameters are optional. The default for the redirect is the account client. The default for the lifespan is 12 hours" ) public Response sendVerifyEmail( @Parameter(description = "Redirect uri") @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri, - @Parameter(description = "Client id") @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId) { - List actions = new LinkedList<>(); - actions.add(UserModel.RequiredAction.VERIFY_EMAIL.name()); - return executeActionsEmail(redirectUri, clientId, null, actions); + @Parameter(description = "Client id") @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId, + @Parameter(description = "Number of seconds after which the generated token expires") @QueryParam("lifespan") Integer lifespan) { + auth.users().requireManage(user); + + SendEmailParams result = verifySendEmailParams(redirectUri, clientId, lifespan); + + int expiration = Time.currentTime() + result.lifespan; + VerifyEmailActionToken token = new VerifyEmailActionToken(user.getId(), expiration, null, user.getEmail(), result.clientId); + token.setRedirectUri(result.redirectUri); + + String link = LoginActionsService.actionTokenProcessor(session.getContext().getUri()) + .queryParam("key", token.serialize(session, realm, session.getContext().getUri())) + .build(realm.getName()).toString(); + + try { + session + .getProvider(EmailTemplateProvider.class) + .setRealm(realm) + .setUser(user) + .sendVerifyEmail(link, TimeUnit.SECONDS.toMinutes(result.lifespan)); + } catch (EmailException e) { + ServicesLogger.LOGGER.failedToSendEmail(e); + throw ErrorResponse.error("Failed to send verify email", Status.INTERNAL_SERVER_ERROR); + } + + adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).success(); + + return Response.noContent().build(); } @GET @@ -1048,4 +1039,57 @@ public class UserResource { rep.setLastAccess(Time.toMillis(clientSession.getTimestamp())); return rep; } + + private SendEmailParams verifySendEmailParams(String redirectUri, String clientId, Integer lifespan) { + if (user.getEmail() == null) { + throw ErrorResponse.error("User email missing", Status.BAD_REQUEST); + } + + if (!user.isEnabled()) { + throw ErrorResponse.error("User is disabled", Status.BAD_REQUEST); + } + + if (redirectUri != null && clientId == null) { + throw ErrorResponse.error("Client id missing", Status.BAD_REQUEST); + } + + if (clientId == null) { + clientId = Constants.ACCOUNT_MANAGEMENT_CLIENT_ID; + } + + ClientModel client = realm.getClientByClientId(clientId); + if (client == null) { + logger.debugf("Client %s doesn't exist", clientId); + throw ErrorResponse.error("Client doesn't exist", Status.BAD_REQUEST); + } + if (!client.isEnabled()) { + logger.debugf("Client %s is not enabled", clientId); + throw ErrorResponse.error("Client is not enabled", Status.BAD_REQUEST); + } + + if (redirectUri != null) { + redirectUri = RedirectUtils.verifyRedirectUri(session, redirectUri, client); + if (redirectUri == null) { + throw ErrorResponse.error("Invalid redirect uri.", Status.BAD_REQUEST); + } + } + + if (lifespan == null) { + lifespan = realm.getActionTokenGeneratedByAdminLifespan(); + } + + return new SendEmailParams(redirectUri, clientId, lifespan); + } + + private static class SendEmailParams { + final String redirectUri; + final String clientId; + final int lifespan; + + public SendEmailParams(String redirectUri, String clientId, Integer lifespan) { + this.redirectUri = redirectUri; + this.clientId = clientId; + this.lifespan = lifespan; + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java index 9b397a1c94..97f5b7220f 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java @@ -89,6 +89,7 @@ import org.keycloak.testsuite.util.ClientBuilder; import org.keycloak.testsuite.util.GreenMailRule; import org.keycloak.testsuite.util.GroupBuilder; import org.keycloak.testsuite.util.MailUtils; +import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.RoleBuilder; import org.keycloak.testsuite.util.UserBuilder; @@ -2300,9 +2301,7 @@ public class UserTest extends AbstractAdminTest { public void sendVerifyEmail() throws IOException { UserRepresentation userRep = new UserRepresentation(); userRep.setUsername("user1"); - String id = createUser(userRep); - UserResource user = realm.users().get(id); try { @@ -2353,17 +2352,118 @@ public class UserTest extends AbstractAdminTest { driver.navigate().to(link); proceedPage.assertCurrent(); - assertThat(proceedPage.getInfo(), Matchers.containsString("Verify Email")); + assertThat(proceedPage.getInfo(), Matchers.containsString("Confirm validity of e-mail address")); proceedPage.clickProceedLink(); - Assert.assertEquals("Your account has been updated.", infoPage.getInfo()); + Assert.assertEquals("Your account has been updated.", infoPage.getInfo()); driver.navigate().to("about:blank"); - driver.navigate().to(link); // It should be possible to use the same action token multiple times + driver.navigate().to(link); + infoPage.assertCurrent(); + assertEquals("Your email address has been verified already.", infoPage.getInfo()); + } + + @Test + public void sendVerifyEmailWithRedirect() throws IOException { + UserRepresentation userRep = new UserRepresentation(); + userRep.setEnabled(true); + userRep.setUsername("user1"); + userRep.setEmail("user1@test.com"); + + String id = createUser(userRep); + + UserResource user = realm.users().get(id); + + String clientId = "test-app"; + String redirectUri = OAuthClient.SERVER_ROOT + "/auth/some-page"; + try { + // test that an invalid redirect uri is rejected. + user.sendVerifyEmail(clientId, "http://unregistered-uri.com/"); + fail("Expected failure"); + } catch (ClientErrorException e) { + assertEquals(400, e.getResponse().getStatus()); + + ErrorRepresentation error = e.getResponse().readEntity(ErrorRepresentation.class); + Assert.assertEquals("Invalid redirect uri.", error.getErrorMessage()); + } + + + user.sendVerifyEmail(clientId, redirectUri); + assertAdminEvents.assertEvent(realmId, OperationType.ACTION, AdminEventPaths.userResourcePath(id) + "/send-verify-email", ResourceType.USER); + + Assert.assertEquals(1, greenMail.getReceivedMessages().length); + + MimeMessage message = greenMail.getReceivedMessages()[0]; + + String link = MailUtils.getPasswordResetEmailLink(message); + + driver.navigate().to(link); + proceedPage.assertCurrent(); - assertThat(proceedPage.getInfo(), Matchers.containsString("Verify Email")); + assertThat(proceedPage.getInfo(), Matchers.containsString("Confirm validity of e-mail address")); proceedPage.clickProceedLink(); - Assert.assertEquals("Your account has been updated.", infoPage.getInfo()); + + assertEquals("Your account has been updated.", infoPage.getInfo()); + + String pageSource = driver.getPageSource(); + Assert.assertTrue(pageSource.contains(redirectUri)); + } + + @Test + public void sendVerifyEmailWithRedirectAndCustomLifespan() throws IOException { + UserRepresentation userRep = new UserRepresentation(); + userRep.setEnabled(true); + userRep.setUsername("user1"); + userRep.setEmail("user1@test.com"); + + String id = createUser(userRep); + + UserResource user = realm.users().get(id); + + final int lifespan = (int) TimeUnit.DAYS.toSeconds(1); + String redirectUri = OAuthClient.SERVER_ROOT + "/auth/some-page"; + try { + // test that an invalid redirect uri is rejected. + user.sendVerifyEmail("test-app", "http://unregistered-uri.com/", lifespan); + fail("Expected failure"); + } catch (ClientErrorException e) { + assertEquals(400, e.getResponse().getStatus()); + + ErrorRepresentation error = e.getResponse().readEntity(ErrorRepresentation.class); + Assert.assertEquals("Invalid redirect uri.", error.getErrorMessage()); + } + + + user.sendVerifyEmail("test-app", redirectUri, lifespan); + assertAdminEvents.assertEvent(realmId, OperationType.ACTION, AdminEventPaths.userResourcePath(id) + "/send-verify-email", ResourceType.USER); + + Assert.assertEquals(1, greenMail.getReceivedMessages().length); + MimeMessage message = greenMail.getReceivedMessages()[0]; + + MailUtils.EmailBody body = MailUtils.getBody(message); + assertThat(body.getText(), Matchers.containsString("This link will expire within 1 day")); + assertThat(body.getHtml(), Matchers.containsString("This link will expire within 1 day")); + + String link = MailUtils.getPasswordResetEmailLink(message); + String token = link.substring(link.indexOf("key=") + "key=".length()); + + try { + final AccessToken accessToken = TokenVerifier.create(token, AccessToken.class).getToken(); + assertEquals(lifespan, accessToken.getExp() - accessToken.getIat()); + } catch (VerificationException e) { + throw new IOException(e); + } + + driver.navigate().to(link); + + proceedPage.assertCurrent(); + assertThat(proceedPage.getInfo(), Matchers.containsString("Confirm validity of e-mail address")); + proceedPage.clickProceedLink(); + + assertEquals("Your account has been updated.", infoPage.getInfo()); + + String pageSource = driver.getPageSource(); + Assert.assertTrue(pageSource.contains(redirectUri)); } @Test