Use email verification instead of executing action for send-verify-email endpoint

Closes #15190

Add support for `send-verify-email` endpoint to use the `email-verification.ftl` instead of `executeActions.ftl`

Also introduce a new parameter `lifespan` to be able to override the default lifespan value (12 hours)

Signed-off-by: Lex Cao <lexcao@foxmail.com>
This commit is contained in:
Lex Cao 2023-09-18 00:42:37 +08:00 committed by Pedro Igor
parent 5eb7363ddd
commit 47f7e3e8f1
5 changed files with 245 additions and 53 deletions

View file

@ -269,6 +269,30 @@ public interface UserResource {
@Path("send-verify-email") @Path("send-verify-email")
void sendVerifyEmail(@QueryParam("client_id") String clientId); 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 @GET
@Path("sessions") @Path("sessions")
List<UserSessionRepresentation> getUserSessions(); List<UserSessionRepresentation> getUserSessions();

View file

@ -29,6 +29,7 @@ public class VerifyEmailActionToken extends DefaultActionToken {
public static final String TOKEN_TYPE = "verify-email"; 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_ORIGINAL_AUTHENTICATION_SESSION_ID = "oasid";
private static final String JSON_FIELD_REDIRECT_URI = "reduri";
@JsonProperty(value = JSON_FIELD_ORIGINAL_AUTHENTICATION_SESSION_ID) @JsonProperty(value = JSON_FIELD_ORIGINAL_AUTHENTICATION_SESSION_ID)
private String originalAuthenticationSessionId; private String originalAuthenticationSessionId;
@ -49,4 +50,18 @@ public class VerifyEmailActionToken extends DefaultActionToken {
public void setCompoundOriginalAuthenticationSessionId(String originalAuthenticationSessionId) { public void setCompoundOriginalAuthenticationSessionId(String originalAuthenticationSessionId) {
this.originalAuthenticationSessionId = 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);
}
}
} }

View file

@ -26,6 +26,8 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserModel.RequiredAction; 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.Urls;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.AuthenticationSessionManager;
@ -107,6 +109,13 @@ public class VerifyEmailActionTokenHandler extends AbstractActionTokenHandler<Ve
user.removeRequiredAction(RequiredAction.VERIFY_EMAIL); user.removeRequiredAction(RequiredAction.VERIFY_EMAIL);
authSession.removeRequiredAction(RequiredAction.VERIFY_EMAIL); authSession.removeRequiredAction(RequiredAction.VERIFY_EMAIL);
String redirectUri = RedirectUtils.verifyRedirectUri(tokenContext.getSession(), token.getRedirectUri(), authSession.getClient());
if (redirectUri != null) {
authSession.setAuthNote(AuthenticationManager.SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS, "true");
authSession.setRedirectUri(redirectUri);
authSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri);
}
event.success(); event.success();
if (token.getCompoundOriginalAuthenticationSessionId() != null) { if (token.getCompoundOriginalAuthenticationSessionId() != null) {

View file

@ -25,6 +25,7 @@ import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache; import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.authentication.actiontoken.execactions.ExecuteActionsActionToken; import org.keycloak.authentication.actiontoken.execactions.ExecuteActionsActionToken;
import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken;
import org.keycloak.authentication.requiredactions.util.RequiredActionsValidator; import org.keycloak.authentication.requiredactions.util.RequiredActionsValidator;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
@ -858,49 +859,14 @@ public class UserResource {
@Parameter(description = "Required actions the user needs to complete") List<String> actions) { @Parameter(description = "Required actions the user needs to complete") List<String> actions) {
auth.users().requireManage(user); auth.users().requireManage(user);
if (user.getEmail() == null) { SendEmailParams result = verifySendEmailParams(redirectUri, clientId, lifespan);
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;
}
if (CollectionUtil.isNotEmpty(actions) && !RequiredActionsValidator.validRequiredActions(session, actions)) { if (CollectionUtil.isNotEmpty(actions) && !RequiredActionsValidator.validRequiredActions(session, actions)) {
throw ErrorResponse.error("Provided invalid required actions", Status.BAD_REQUEST); throw ErrorResponse.error("Provided invalid required actions", Status.BAD_REQUEST);
} }
ClientModel client = realm.getClientByClientId(clientId); int expiration = Time.currentTime() + result.lifespan;
if (client == null) { ExecuteActionsActionToken token = new ExecuteActionsActionToken(user.getId(), user.getEmail(), expiration, actions, result.redirectUri, result.clientId);
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);
try { try {
UriBuilder builder = LoginActionsService.actionTokenProcessor(session.getContext().getUri()); UriBuilder builder = LoginActionsService.actionTokenProcessor(session.getContext().getUri());
@ -912,7 +878,7 @@ public class UserResource {
.setAttribute(Constants.TEMPLATE_ATTR_REQUIRED_ACTIONS, token.getRequiredActions()) .setAttribute(Constants.TEMPLATE_ATTR_REQUIRED_ACTIONS, token.getRequiredActions())
.setRealm(realm) .setRealm(realm)
.setUser(user) .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(); //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 redirectUri Redirect uri
* @param clientId Client id * @param clientId Client id
* @param lifespan Number of seconds after which the generated token expires
* @return * @return
*/ */
@Path("send-verify-email") @Path("send-verify-email")
@ -942,14 +909,38 @@ public class UserResource {
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS) @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
@Operation( @Operation(
summary = "Send an email-verification email to the user An email contains a link the user can click to verify their email address.", 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." 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( public Response sendVerifyEmail(
@Parameter(description = "Redirect uri") @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri, @Parameter(description = "Redirect uri") @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri,
@Parameter(description = "Client id") @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId) { @Parameter(description = "Client id") @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId,
List<String> actions = new LinkedList<>(); @Parameter(description = "Number of seconds after which the generated token expires") @QueryParam("lifespan") Integer lifespan) {
actions.add(UserModel.RequiredAction.VERIFY_EMAIL.name()); auth.users().requireManage(user);
return executeActionsEmail(redirectUri, clientId, null, actions);
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 @GET
@ -1048,4 +1039,57 @@ public class UserResource {
rep.setLastAccess(Time.toMillis(clientSession.getTimestamp())); rep.setLastAccess(Time.toMillis(clientSession.getTimestamp()));
return rep; 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;
}
}
} }

View file

@ -89,6 +89,7 @@ import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.GreenMailRule; import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.GroupBuilder; import org.keycloak.testsuite.util.GroupBuilder;
import org.keycloak.testsuite.util.MailUtils; import org.keycloak.testsuite.util.MailUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.RoleBuilder; import org.keycloak.testsuite.util.RoleBuilder;
import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserBuilder;
@ -2300,9 +2301,7 @@ public class UserTest extends AbstractAdminTest {
public void sendVerifyEmail() throws IOException { public void sendVerifyEmail() throws IOException {
UserRepresentation userRep = new UserRepresentation(); UserRepresentation userRep = new UserRepresentation();
userRep.setUsername("user1"); userRep.setUsername("user1");
String id = createUser(userRep); String id = createUser(userRep);
UserResource user = realm.users().get(id); UserResource user = realm.users().get(id);
try { try {
@ -2353,17 +2352,118 @@ public class UserTest extends AbstractAdminTest {
driver.navigate().to(link); driver.navigate().to(link);
proceedPage.assertCurrent(); proceedPage.assertCurrent();
assertThat(proceedPage.getInfo(), Matchers.containsString("Verify Email")); assertThat(proceedPage.getInfo(), Matchers.containsString("Confirm validity of e-mail address"));
proceedPage.clickProceedLink(); 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("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(); proceedPage.assertCurrent();
assertThat(proceedPage.getInfo(), Matchers.containsString("Verify Email")); assertThat(proceedPage.getInfo(), Matchers.containsString("Confirm validity of e-mail address"));
proceedPage.clickProceedLink(); 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 @Test