Token expiration tests and updates to registration required action
Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
parent
158162fb4f
commit
40a283b9e8
2 changed files with 145 additions and 49 deletions
|
@ -27,15 +27,23 @@ import org.keycloak.authentication.FormActionFactory;
|
|||
import org.keycloak.authentication.FormContext;
|
||||
import org.keycloak.authentication.ValidationContext;
|
||||
import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
|
||||
import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionTokenHandler;
|
||||
import org.keycloak.authentication.requiredactions.TermsAndConditions;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.Profile.Feature;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.forms.login.LoginFormsProvider;
|
||||
import org.keycloak.models.*;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RequiredActionProviderModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.utils.FormMessage;
|
||||
import org.keycloak.organization.OrganizationProvider;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
|
@ -50,6 +58,7 @@ import org.keycloak.userprofile.UserProfile;
|
|||
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||
|
@ -72,13 +81,16 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
|
|||
@Override
|
||||
public void validate(ValidationContext context) {
|
||||
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
|
||||
MultivaluedMap<String, String> queryParameters = context.getHttpRequest().getUri().getQueryParameters();
|
||||
|
||||
context.getEvent().detail(Details.REGISTER_METHOD, "form");
|
||||
|
||||
UserProfile profile = getOrCreateUserProfile(context, formData);
|
||||
Attributes attributes = profile.getAttributes();
|
||||
String email = attributes.getFirst(UserModel.EMAIL);
|
||||
|
||||
if (!validateOrganizationInvitation(context, formData, email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String username = attributes.getFirst(UserModel.USERNAME);
|
||||
String firstName = attributes.getFirst(UserModel.FIRST_NAME);
|
||||
String lastName = attributes.getFirst(UserModel.LAST_NAME);
|
||||
|
@ -112,27 +124,6 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
|
|||
context.validationError(formData, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle parsing of an organization invite token from the url
|
||||
String tokenFromQuery = queryParameters.getFirst(Constants.ORG_TOKEN);
|
||||
if (tokenFromQuery != null) {
|
||||
TokenVerifier<InviteOrgActionToken> tokenVerifier = TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class);
|
||||
try {
|
||||
InviteOrgActionToken aToken = tokenVerifier.getToken();
|
||||
if (aToken.isExpired() || !aToken.getActionId().equals(InviteOrgActionToken.TOKEN_TYPE) || !aToken.getEmail().equals(email)) {
|
||||
throw new VerificationException("The provided token is not valid. It may be expired or issued for a different email");
|
||||
}
|
||||
// TODO probably need to check if string is empty or null
|
||||
if (context.getSession().getProvider(OrganizationProvider.class).getById(aToken.getOrgId()) == null) {
|
||||
throw new VerificationException("The provided token contains an invalid organization id");
|
||||
}
|
||||
} catch (VerificationException e) {
|
||||
// TODO we can be more specific here just trying to get something working...
|
||||
context.getEvent().detail(Messages.INVALID_ORG_INVITE, tokenFromQuery);
|
||||
context.error(Errors.INVALID_TOKEN);
|
||||
return;
|
||||
}
|
||||
}
|
||||
context.success();
|
||||
}
|
||||
|
||||
|
@ -146,19 +137,6 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
|
|||
checkNotOtherUserAuthenticating(context);
|
||||
|
||||
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
|
||||
String tokenFromQuery = context.getHttpRequest().getUri().getQueryParameters().getFirst(Constants.ORG_TOKEN);
|
||||
|
||||
DefaultActionTokenKey aToken = null;
|
||||
if(tokenFromQuery != null) {
|
||||
try {
|
||||
TokenVerifier<DefaultActionTokenKey> tokenVerifier = TokenVerifier.create(tokenFromQuery, DefaultActionTokenKey.class);
|
||||
aToken = tokenVerifier.getToken();
|
||||
} catch (VerificationException e) {
|
||||
// TODO in theory this should never happen since we already validated. We should either encapsulate decoding the token somehow (add to context or make new class?)
|
||||
// for now we can panic run this exception if we somehow end up here
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
String email = formData.getFirst(UserModel.EMAIL);
|
||||
String username = formData.getFirst(UserModel.USERNAME);
|
||||
|
@ -174,16 +152,7 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
|
|||
UserProfile profile = getOrCreateUserProfile(context, formData);
|
||||
UserModel user = profile.create();
|
||||
|
||||
// since we already validated the token we can just add the user to the organization
|
||||
if (aToken != null) {
|
||||
String org = aToken.getOtherClaims().get("org_id").toString();
|
||||
KeycloakSession session = context.getSession();
|
||||
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel orgModel = provider.getById(org);
|
||||
provider.addMember(orgModel, user);
|
||||
context.getEvent().detail(Details.ORG_ID, org);
|
||||
context.getAuthenticationSession().setRedirectUri(aToken.getOtherClaims().get("reduri").toString());
|
||||
}
|
||||
addOrganizationMember(context, user);
|
||||
|
||||
user.setEnabled(true);
|
||||
|
||||
|
@ -318,4 +287,71 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
|
|||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
private boolean validateOrganizationInvitation(ValidationContext context, MultivaluedMap<String, String> formData, String email) {
|
||||
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
|
||||
MultivaluedMap<String, String> queryParameters = context.getHttpRequest().getUri().getQueryParameters();
|
||||
String tokenFromQuery = queryParameters.getFirst(Constants.ORG_TOKEN);
|
||||
|
||||
if (tokenFromQuery == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Consumer<List<FormMessage>> error = messages -> {
|
||||
context.getEvent().detail(Messages.INVALID_ORG_INVITE, tokenFromQuery);
|
||||
context.error(Errors.INVALID_TOKEN);
|
||||
context.validationError(formData, messages);
|
||||
};
|
||||
|
||||
TokenVerifier<InviteOrgActionToken> tokenVerifier = TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class);
|
||||
InviteOrgActionToken token;
|
||||
|
||||
try {
|
||||
token = tokenVerifier.getToken();
|
||||
} catch (VerificationException e) {
|
||||
error.accept(List.of(new FormMessage("Unexpected error parsing the invitation token")));
|
||||
return false;
|
||||
}
|
||||
|
||||
KeycloakSession session = context.getSession();
|
||||
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel organization = provider.getById(token.getOrgId());
|
||||
|
||||
if (organization == null) {
|
||||
error.accept(List.of(new FormMessage("The provided token contains an invalid organization id")));
|
||||
return false;
|
||||
}
|
||||
|
||||
// make sure the organization is set to the session so that UP org-related validators can run
|
||||
session.setAttribute(OrganizationModel.class.getName(), organization);
|
||||
session.setAttribute(InviteOrgActionToken.class.getName(), token);
|
||||
|
||||
if (token.isExpired() || !token.getActionId().equals(InviteOrgActionToken.TOKEN_TYPE)) {
|
||||
error.accept(List.of(new FormMessage("The provided token is not valid or has expired.")));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!token.getEmail().equals(email)) {
|
||||
error.accept(List.of(new FormMessage(UserModel.EMAIL, "Email does not match the invitation")));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void addOrganizationMember(FormContext context, UserModel user) {
|
||||
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
|
||||
InviteOrgActionToken token = (InviteOrgActionToken) context.getSession().getAttribute(InviteOrgActionToken.class.getName());
|
||||
|
||||
if (token != null) {
|
||||
KeycloakSession session = context.getSession();
|
||||
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel orgModel = provider.getById(token.getOrgId());
|
||||
provider.addMember(orgModel, user);
|
||||
context.getEvent().detail(Details.ORG_ID, orgModel.getId());
|
||||
context.getAuthenticationSession().setRedirectUri(token.getRedirectUri());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import static org.hamcrest.Matchers.containsString;
|
|||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
@ -98,7 +99,6 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
|
|||
Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation());
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testInviteNewUserRegistration() throws IOException {
|
||||
UserRepresentation user = UserBuilder.create()
|
||||
|
@ -124,9 +124,69 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
|
|||
Assert.assertFalse(users.isEmpty());
|
||||
// user is a member
|
||||
Assert.assertNotNull(organization.members().member(users.get(0).getId()).toRepresentation());
|
||||
getCleanup().addCleanup(() -> testRealm().users().get(users.get(0).getId()).remove());
|
||||
|
||||
// authenticated to the account console
|
||||
Assert.assertTrue(driver.getPageSource().contains("Account Management"));
|
||||
Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEmailDoesNotChangeOnRegistration() throws IOException {
|
||||
UserRepresentation user = UserBuilder.create()
|
||||
.username("invitedUser")
|
||||
.email("inviteduser@email")
|
||||
.enabled(true)
|
||||
.build();
|
||||
// User isn't created when we send the invite
|
||||
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
|
||||
organization.members().inviteUser(user.getEmail()).close();
|
||||
|
||||
MimeMessage message = greenMail.getLastReceivedMessage();
|
||||
Assert.assertNotNull(message);
|
||||
String link = MailUtils.getPasswordResetEmailLink(message);
|
||||
String orgToken = UriUtils.parseQueryParameters(link, false).values().stream().map(strings -> strings.get(0)).findFirst().orElse(null);
|
||||
Assert.assertNotNull(orgToken);
|
||||
driver.navigate().to(link.trim());
|
||||
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
|
||||
registerPage.assertCurrent();
|
||||
driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS);
|
||||
registerPage.register("firstName", "lastName", "invalid@email.com",
|
||||
user.getUsername(), "password", "password", null, false, null);
|
||||
Assert.assertTrue(driver.getPageSource().contains("Email does not match the invitation"));
|
||||
List<UserRepresentation> users = testRealm().users().searchByEmail(user.getEmail(), true);
|
||||
Assert.assertTrue(users.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLinkExpired() throws IOException {
|
||||
UserRepresentation user = UserBuilder.create()
|
||||
.username("invitedUser")
|
||||
.email("inviteduser@email")
|
||||
.enabled(true)
|
||||
.build();
|
||||
// User isn't created when we send the invite
|
||||
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
|
||||
organization.members().inviteUser(user.getEmail()).close();
|
||||
|
||||
try {
|
||||
setTimeOffset((int) TimeUnit.DAYS.toSeconds(1));
|
||||
MimeMessage message = greenMail.getLastReceivedMessage();
|
||||
Assert.assertNotNull(message);
|
||||
String link = MailUtils.getPasswordResetEmailLink(message);
|
||||
String orgToken = UriUtils.parseQueryParameters(link, false).values().stream().map(strings -> strings.get(0)).findFirst().orElse(null);
|
||||
Assert.assertNotNull(orgToken);
|
||||
driver.navigate().to(link.trim());
|
||||
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
|
||||
registerPage.assertCurrent();
|
||||
driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS);
|
||||
registerPage.register("firstName", "lastName", "invalid@email.com",
|
||||
user.getUsername(), "password", "password", null, false, null);
|
||||
Assert.assertTrue(driver.getPageSource().contains("The provided token is not valid or has expired."));
|
||||
List<UserRepresentation> users = testRealm().users().searchByEmail(user.getEmail(), true);
|
||||
Assert.assertTrue(users.isEmpty());
|
||||
} finally {
|
||||
resetTimeOffset();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue