Token expiration tests and updates to registration required action

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-05-03 12:40:45 -03:00
parent 158162fb4f
commit 40a283b9e8
2 changed files with 145 additions and 49 deletions

View file

@ -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());
}
}
}
}

View file

@ -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();
}
}
}