trigger REMOVE_TOTP event on removal of an OTP credential

Closes #15403

Signed-off-by: Theresa Henze <theresa.henze@bare.id>
This commit is contained in:
Theresa Henze 2023-07-19 17:15:53 +02:00 committed by Alexander Schwartz
parent 43727aa10f
commit 653d09f39a
4 changed files with 91 additions and 4 deletions

View file

@ -87,6 +87,7 @@ public interface Details {
String CREDENTIAL_TYPE = "credential_type"; String CREDENTIAL_TYPE = "credential_type";
String SELECTED_CREDENTIAL_ID = "selected_credential_id"; String SELECTED_CREDENTIAL_ID = "selected_credential_id";
String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail"; String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail";
String CREDENTIAL_USER_LABEL = "credential_user_label";
String NOT_BEFORE = "not_before"; String NOT_BEFORE = "not_before";
String NUM_FAILURES = "num_failures"; String NUM_FAILURES = "num_failures";

View file

@ -11,12 +11,16 @@ import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialProviderFactory; import org.keycloak.credential.CredentialProviderFactory;
import org.keycloak.credential.CredentialTypeMetadata; import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.credential.CredentialTypeMetadataContext; import org.keycloak.credential.CredentialTypeMetadataContext;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.AccountRoles; import org.keycloak.models.AccountRoles;
import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.KeycloakSession; 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.credential.OTPCredentialModel;
import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.account.CredentialMetadataRepresentation; import org.keycloak.representations.account.CredentialMetadataRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.CredentialRepresentation;
@ -61,11 +65,13 @@ public class AccountCredentialResource {
private final UserModel user; private final UserModel user;
private final RealmModel realm; private final RealmModel realm;
private Auth auth; private Auth auth;
private final EventBuilder event;
public AccountCredentialResource(KeycloakSession session, UserModel user, Auth auth) { public AccountCredentialResource(KeycloakSession session, UserModel user, Auth auth, EventBuilder event) {
this.session = session; this.session = session;
this.user = user; this.user = user;
this.auth = auth; this.auth = auth;
this.event = event;
realm = session.getContext().getRealm(); realm = session.getContext().getRealm();
} }
@ -297,11 +303,17 @@ public class AccountCredentialResource {
user.credentialManager().disableCredentialType(credentialType); user.credentialManager().disableCredentialType(credentialType);
return; return;
} }
throw new NotFoundException("Credential not found"); throw new NotFoundException("Credential not found");
} }
checkIfCanBeRemoved(credential.getType()); checkIfCanBeRemoved(credential.getType());
user.credentialManager().removeStoredCredentialById(credentialId); user.credentialManager().removeStoredCredentialById(credentialId);
if (OTPCredentialModel.TYPE.equals(credential.getType())) {
event.event(EventType.REMOVE_TOTP)
.detail(Details.SELECTED_CREDENTIAL_ID, credentialId)
.detail(Details.CREDENTIAL_USER_LABEL, credential.getUserLabel());
event.success();
}
} }

View file

@ -214,7 +214,7 @@ public class AccountRestService {
@Path("/credentials") @Path("/credentials")
public AccountCredentialResource credentials() { public AccountCredentialResource credentials() {
checkAccountApiEnabled(); checkAccountApiEnabled();
return new AccountCredentialResource(session, user, auth); return new AccountCredentialResource(session, user, auth, event);
} }
@Path("/resources") @Path("/resources")

View file

@ -44,6 +44,7 @@ import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.WebAuthnCredentialModel; import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.models.utils.DefaultAuthenticationFlows; import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.account.ClientRepresentation; import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation; import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation; import org.keycloak.representations.account.ConsentScopeRepresentation;
@ -789,6 +790,79 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
Assert.assertTrue(ObjectUtil.isEqualOrBothNull(otpCredential.getUserLabel(), otpCredentialLoaded.getUserLabel())); Assert.assertTrue(ObjectUtil.isEqualOrBothNull(otpCredential.getUserLabel(), otpCredentialLoaded.getUserLabel()));
} }
@Test
public void testRemoveCredentialWithNonOtpCredentialTriggeringNoEvent() throws IOException {
List<AccountCredentialResource.CredentialContainer> credentials = getCredentials();
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost");
assertEquals(1, user.credentials().size());
// Add non-OTP credential to the user through admin REST API
CredentialRepresentation nonOtpCredential = ModelToRepresentation.toRepresentation(
WebAuthnCredentialModel.create(WebAuthnCredentialModel.TYPE_TWOFACTOR, "foo", "foo", "foo", "foo", "foo", 2L, "foo"));
org.keycloak.representations.idm.UserRepresentation userRep = UserBuilder.edit(user.toRepresentation())
.secret(nonOtpCredential)
.build();
user.update(userRep);
credentials = getCredentials();
Assert.assertEquals(2, credentials.size());
Assert.assertTrue(credentials.get(1).isRemoveable());
// Remove credential
CredentialRepresentation credential = user.credentials().stream()
.filter(credentialRep -> WebAuthnCredentialModel.TYPE_TWOFACTOR.equals(credentialRep.getType()))
.findFirst()
.get();
Assert.assertNotNull(credential);
user.removeCredential(credential.getId());
events.poll();
events.assertEmpty();
}
@Test
public void testRemoveCredentialWithOtpCredentialTriggeringEvent() throws IOException {
List<AccountCredentialResource.CredentialContainer> credentials = getCredentials();
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost");
assertEquals(1, user.credentials().size());
// Add OTP credential to the user through admin REST API
org.keycloak.representations.idm.UserRepresentation userRep = UserBuilder.edit(user.toRepresentation())
.totpSecret("totpSecret")
.build();
userRep.getCredentials().get(0).setUserLabel("totpCredentialUserLabel");
user.update(userRep);
credentials = getCredentials();
Assert.assertEquals(2, credentials.size());
Assert.assertTrue(credentials.get(1).isRemoveable());
// Remove credential
CredentialRepresentation otpCredential = user.credentials().stream()
.filter(credentialRep -> OTPCredentialModel.TYPE.equals(credentialRep.getType()))
.findFirst()
.get();
SimpleHttp.Response response = SimpleHttp
.doDelete(getAccountUrl("credentials/" + otpCredential.getId()), httpClient)
.acceptJson()
.auth(tokenUtil.getToken())
.asResponse();
assertEquals(204, response.getStatus());
events.poll();
events.expect(EventType.REMOVE_TOTP)
.client("account")
.user(user.toRepresentation().getId())
.detail(Details.SELECTED_CREDENTIAL_ID, otpCredential.getId())
.detail(Details.CREDENTIAL_USER_LABEL, "totpCredentialUserLabel")
.assertEvent();
events.assertEmpty();
}
// Send REST request to get all credential containers and credentials of current user // Send REST request to get all credential containers and credentials of current user
private List<AccountCredentialResource.CredentialContainer> getCredentials() throws IOException { private List<AccountCredentialResource.CredentialContainer> getCredentials() throws IOException {
return SimpleHttp.doGet(getAccountUrl("credentials"), httpClient) return SimpleHttp.doGet(getAccountUrl("credentials"), httpClient)
@ -1688,7 +1762,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
testRealm().update(realmRep); testRealm().update(realmRep);
} }
} }
@EnableFeature(Profile.Feature.UPDATE_EMAIL) @EnableFeature(Profile.Feature.UPDATE_EMAIL)
public void testEmailWhenUpdateEmailEnabled() throws Exception { public void testEmailWhenUpdateEmailEnabled() throws Exception {
reconnectAdminClient(); reconnectAdminClient();