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:
parent
5eb7363ddd
commit
47f7e3e8f1
5 changed files with 245 additions and 53 deletions
|
@ -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<UserSessionRepresentation> getUserSessions();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Ve
|
|||
user.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();
|
||||
|
||||
if (token.getCompoundOriginalAuthenticationSessionId() != null) {
|
||||
|
|
|
@ -25,6 +25,7 @@ import org.jboss.logging.Logger;
|
|||
import org.jboss.resteasy.reactive.NoCache;
|
||||
import org.keycloak.authentication.RequiredActionProvider;
|
||||
import org.keycloak.authentication.actiontoken.execactions.ExecuteActionsActionToken;
|
||||
import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken;
|
||||
import org.keycloak.authentication.requiredactions.util.RequiredActionsValidator;
|
||||
import org.keycloak.common.ClientConnection;
|
||||
import org.keycloak.common.Profile;
|
||||
|
@ -858,49 +859,14 @@ public class UserResource {
|
|||
@Parameter(description = "Required actions the user needs to complete") List<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue