From d918967281652ead0130c5dbef663e82003d4393 Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Sun, 22 Aug 2021 10:57:02 +0300 Subject: ActivityPub: handle user deletion for suspended users --- src/main/java/com/juick/SignatureManager.java | 54 ++++++++++++---------- .../java/com/juick/www/api/activity/Profile.java | 2 +- .../www/api/activity/model/objects/Actor.java | 10 ++++ .../java/com/juick/server/MockDeleteListener.java | 30 ++++++++++++ .../java/com/juick/server/tests/ServerTests.java | 40 +++++++++++++++- .../snapshots/activity/test_suspended_user.json | 47 +++++++++++++++++++ .../resources/snapshots/activity/testdelete.json | 8 ++++ 7 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 src/test/java/com/juick/server/MockDeleteListener.java create mode 100644 src/test/resources/snapshots/activity/test_suspended_user.json create mode 100644 src/test/resources/snapshots/activity/testdelete.json diff --git a/src/main/java/com/juick/SignatureManager.java b/src/main/java/com/juick/SignatureManager.java index 668669f1..e9cb4f6a 100644 --- a/src/main/java/com/juick/SignatureManager.java +++ b/src/main/java/com/juick/SignatureManager.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.juick.model.AnonymousUser; import com.juick.model.User; import com.juick.service.UserService; +import com.juick.service.activities.DeleteUserEvent; import com.juick.util.DateFormattersHolder; import com.juick.www.api.activity.model.Context; import com.juick.www.api.activity.model.objects.Person; @@ -30,6 +31,7 @@ import com.juick.www.api.webfinger.model.Link; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -69,6 +71,8 @@ public class SignatureManager { private UserService userService; @Inject private RestTemplate apClient; + @Inject + private ApplicationEventPublisher applicationEventPublisher; public void post(Person from, Person to, Context data) throws IOException, NoSuchAlgorithmException { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(to.getInbox()); @@ -94,21 +98,17 @@ public class SignatureManager { logger.info("Remote response: {}", response.getStatusCodeValue()); } - public String addSignature(Actor from, String host, String method, - String path, String dateString, String digestHeader) - throws IOException { - return addSignature(from, host, method, path, dateString, - digestHeader, keystoreManager); + public String addSignature(Actor from, String host, String method, String path, String dateString, + String digestHeader) throws IOException { + return addSignature(from, host, method, path, dateString, digestHeader, keystoreManager); } - public String addSignature(Actor from, String host, String method, - String path, String dateString, String digestHeader, - KeystoreManager keystoreManager) throws IOException { - List requiredHeaders = StringUtils.isEmpty(digestHeader) ? - Arrays.asList("(request-target)", "host", "date") + public String addSignature(Actor from, String host, String method, String path, String dateString, + String digestHeader, KeystoreManager keystoreManager) throws IOException { + List requiredHeaders = StringUtils.isEmpty(digestHeader) + ? Arrays.asList("(request-target)", "host", "date") : Arrays.asList("(request-target)", "host", "date", "digest"); - Signature templateSignature = new Signature(from.getPublicKey().getId(), - "rsa-sha256", null, requiredHeaders); + Signature templateSignature = new Signature(from.getPublicKey().getId(), "rsa-sha256", null, requiredHeaders); Map headers = new HashMap<>(); headers.put("host", host); headers.put("date", dateString); @@ -125,11 +125,11 @@ public class SignatureManager { String signatureString = headers.get("signature"); logger.info("Signature: {}", signatureString); Signature signature = Signature.fromString(signatureString); - Optional context = getContext(UriComponentsBuilder.fromUriString(signature.getKeyId()) - .fragment(null).build().toUri()); + Optional context = getContext( + UriComponentsBuilder.fromUriString(signature.getKeyId()).fragment(null).build().toUri()); if (context.isPresent() && context.get() instanceof Actor) { - Actor securityObject = (Actor) context.get(); - Key key = KeystoreManager.publicKeyOf(securityObject); + Actor actor = (Actor) context.get(); + Key key = KeystoreManager.publicKeyOf(actor); Verifier verifier = new Verifier(key, signature); try { @@ -137,22 +137,27 @@ public class SignatureManager { logger.info("signature of {} is valid: {}", signature.getKeyId(), result); if (result) { User user = new User(); - user.setUri(URI.create(securityObject.getId())); + user.setUri(URI.create(actor.getId())); if (key.equals(keystoreManager.getPublicKey())) { - return userService.getUserByName(securityObject.getName()); + return userService.getUserByName(actor.getName()); + } + if (actor.isSuspended()) { + logger.info("{} is suspended, deleting", actor.getId()); + applicationEventPublisher.publishEvent(new DeleteUserEvent(this, actor.getId())); } return user; } else { return AnonymousUser.INSTANCE; } } catch (NoSuchAlgorithmException | SignatureException | MissingRequiredHeaderException | IOException e) { - logger.warn("Invalid signature {}", signatureString); + logger.warn("Invalid signature {}: {}", signatureString, e.getMessage()); } } else { logger.warn("Unknown keyId"); } return AnonymousUser.INSTANCE; } + public Optional getContext(URI contextUri) { try { Context context = apClient.getForEntity(contextUri, Context.class).getBody(); @@ -166,17 +171,16 @@ public class SignatureManager { } return Optional.empty(); } + public Optional discoverPerson(String acct) { Jid acctId = Jid.of(acct); - URI resourceUri = UriComponentsBuilder.fromPath("/.well-known/webfinger") - .host(acctId.getDomain()) - .scheme("https") - .queryParam("resource", String.format("%s", acctId.toEscapedString())).build().toUri(); + URI resourceUri = UriComponentsBuilder.fromPath("/.well-known/webfinger").host(acctId.getDomain()) + .scheme("https").queryParam("resource", String.format("%s", acctId.toEscapedString())).build().toUri(); HttpHeaders headers = new HttpHeaders(); headers.add("Accept", "application/jrd+json"); HttpEntity webfingerRequest = new HttpEntity<>(headers); - ResponseEntity response = apClient.exchange( - resourceUri, HttpMethod.GET, webfingerRequest, Account.class); + ResponseEntity response = apClient.exchange(resourceUri, HttpMethod.GET, webfingerRequest, + Account.class); if (response.getStatusCode().is2xxSuccessful()) { Account acctData = response.getBody(); if (acctData != null) { diff --git a/src/main/java/com/juick/www/api/activity/Profile.java b/src/main/java/com/juick/www/api/activity/Profile.java index 724e0747..cf5fb843 100644 --- a/src/main/java/com/juick/www/api/activity/Profile.java +++ b/src/main/java/com/juick/www/api/activity/Profile.java @@ -376,7 +376,7 @@ public class Profile { if (activity instanceof Delete) { // Delete gone user // TODO: check if it is really deleted and remove copy-paste - if (activity.getActor().equals(activity.getObject().getUrl())) { + if (activity.getActor().equals(activity.getObject().getId())) { return new ResponseEntity<>(CommandResult.fromString("Delete request accepted"), HttpStatus.ACCEPTED); } diff --git a/src/main/java/com/juick/www/api/activity/model/objects/Actor.java b/src/main/java/com/juick/www/api/activity/model/objects/Actor.java index f8bd63e0..7e799a95 100644 --- a/src/main/java/com/juick/www/api/activity/model/objects/Actor.java +++ b/src/main/java/com/juick/www/api/activity/model/objects/Actor.java @@ -23,6 +23,8 @@ import com.juick.www.api.activity.model.Context; public class Actor extends Context { private String preferredUsername; + private boolean suspended; + private String inbox; private String outbox; private String following; @@ -78,4 +80,12 @@ public class Actor extends Context { public void setPreferredUsername(String preferredUsername) { this.preferredUsername = preferredUsername; } + + public boolean isSuspended() { + return suspended; + } + + public void setSuspended(boolean suspended) { + this.suspended = suspended; + } } diff --git a/src/test/java/com/juick/server/MockDeleteListener.java b/src/test/java/com/juick/server/MockDeleteListener.java new file mode 100644 index 00000000..d060d865 --- /dev/null +++ b/src/test/java/com/juick/server/MockDeleteListener.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2008-2021, Juick + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.juick.server; + +import com.juick.service.activities.DeleteUserEvent; +import org.springframework.context.ApplicationListener; + +import javax.annotation.Nonnull; + +public class MockDeleteListener implements ApplicationListener { + @Override + public void onApplicationEvent(@Nonnull DeleteUserEvent event) { + + } +} diff --git a/src/test/java/com/juick/server/tests/ServerTests.java b/src/test/java/com/juick/server/tests/ServerTests.java index 2f60f5be..db1449f2 100644 --- a/src/test/java/com/juick/server/tests/ServerTests.java +++ b/src/test/java/com/juick/server/tests/ServerTests.java @@ -48,6 +48,7 @@ import com.juick.www.api.webfinger.model.Account; import com.juick.www.api.xnodeinfo2.model.NodeInfo; import com.juick.www.WebApp; import com.juick.service.*; +import com.juick.service.activities.DeleteUserEvent; import com.juick.service.activities.UpdateEvent; import com.juick.service.component.SystemEvent; import com.juick.test.util.MockUtils; @@ -229,6 +230,10 @@ public class ServerTests { private Resource testappResponse; @Value("classpath:snapshots/activity/testfollow.json") private Resource testfollowRequest; + @Value("classpath:snapshots/activity/testdelete.json") + private Resource testDeleteRequest; + @Value("classpath:snapshots/activity/test_suspended_user.json") + private Resource testSuspendedUserResponse; @Value("classpath:snapshots/email/subscription.html") private Resource testSubscriptionHtmlEmail; @Value("classpath:snapshots/email/private.html") @@ -2077,7 +2082,7 @@ public class ServerTests { } @Test - public void federatedUserDeletionFlow() throws Exception { + public void federatedUserDeletionFlowWhenItIsGone() throws Exception { String deleteJsonStr = IOUtils.toString(new ClassPathResource("delete_user.json").getURI(), StandardCharsets.UTF_8); Delete delete = jsonMapper.readValue(deleteJsonStr, Delete.class); @@ -2096,6 +2101,39 @@ public class ServerTests { apClient.setRequestFactory(originalRequestFactory); } + @MockBean + private MockDeleteListener deleteListener; + @Captor + protected ArgumentCaptor deleteEventCaptor; + + @Test + public void federatedUserDeletionFlowWhenItIsSuspended() throws Exception { + String deleteJsonStr = IOUtils.toString(testDeleteRequest.getInputStream(), StandardCharsets.UTF_8); + Delete delete = jsonMapper.readValue(deleteJsonStr, Delete.class); + ClientHttpRequestFactory originalRequestFactory = apClient.getRequestFactory(); + MockRestServiceServer restServiceServer = MockRestServiceServer.createServer(apClient); + restServiceServer.expect(times(2), requestTo(delete.getObject().getId())) + .andRespond(withSuccess(IOUtils.toString(testSuspendedUserResponse.getInputStream(), StandardCharsets.UTF_8), MediaType.APPLICATION_JSON)); + Person testuser = (Person) signatureManager.getContext(URI.create(delete.getObject().getId())).get(); + Instant now = Instant.now(); + String testRequestDate = DateFormattersHolder.getHttpDateFormatter().format(now); + String inboxUri = "/api/inbox"; + byte[] digest = MessageDigest.getInstance("SHA-256").digest(deleteJsonStr.getBytes()); + String digestHeader = "SHA-256=" + new String(Base64.encodeBase64(digest)); + String testSignatureString = signatureManager.addSignature(testuser, "localhost", "POST", inboxUri, + testRequestDate, digestHeader, testKeystoreManager); + mockMvc.perform(post(inboxUri).contentType(ACTIVITY_MEDIA_TYPE).content(deleteJsonStr) + .header("Host", "localhost") + .header("Date", testRequestDate) + .header("Digest", digestHeader) + .header("Signature", testSignatureString)) + .andExpect(status().isAccepted()); + apClient.setRequestFactory(originalRequestFactory); + Mockito.verify(deleteListener, Mockito.times(1)).onApplicationEvent(deleteEventCaptor.capture()); + DeleteUserEvent receivedEvent = deleteEventCaptor.getValue(); + assertThat(receivedEvent.getUserUri(), is(testuser.getId())); + } + @Test @Order(2) public void handleIncorrectCertificates() throws Exception { diff --git a/src/test/resources/snapshots/activity/test_suspended_user.json b/src/test/resources/snapshots/activity/test_suspended_user.json new file mode 100644 index 00000000..b0d8e97b --- /dev/null +++ b/src/test/resources/snapshots/activity/test_suspended_user.json @@ -0,0 +1,47 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "featured": { "@id": "toot:featured", "@type": "@id" }, + "featuredTags": { "@id": "toot:featuredTags", "@type": "@id" }, + "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" }, + "movedTo": { "@id": "as:movedTo", "@type": "@id" }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "IdentityProof": "toot:IdentityProof", + "discoverable": "toot:discoverable", + "Device": "toot:Device", + "Ed25519Signature": "toot:Ed25519Signature", + "Ed25519Key": "toot:Ed25519Key", + "Curve25519Key": "toot:Curve25519Key", + "EncryptedMessage": "toot:EncryptedMessage", + "publicKeyBase64": "toot:publicKeyBase64", + "deviceId": "toot:deviceId", + "claim": { "@type": "@id", "@id": "toot:claim" }, + "fingerprintKey": { "@type": "@id", "@id": "toot:fingerprintKey" }, + "identityKey": { "@type": "@id", "@id": "toot:identityKey" }, + "devices": { "@type": "@id", "@id": "toot:devices" }, + "messageFranking": "toot:messageFranking", + "messageType": "toot:messageType", + "cipherText": "toot:cipherText", + "suspended": "toot:suspended" + } + ], + "id": "https://example.com/u/testuser", + "type": "Person", + "url": "https://example.com/u/testuser", + "manuallyApprovesFollowers": false, + "discoverable": false, + "suspended": true, + "publicKey": { + "id": "https://example.com/u/testuser#main-key", + "owner": "https://example.com/u/testuser", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiHKRdKFFeT4P/MVlNbxC\nbbgXOkEdeQzvJB/wAJgSYbUwm9SzNFzttePQXk3/MWoK2awWUInZTduVHsWt8zU7\nO3d9PAW6YH6L1oDkjgMLAb9aUWV2ClQWMwsn88WKK9Rb1WOmd8BrXjPfmeFK2ypQ\n9eg8aKpH36WAXiiaTDfBupBZ0Ki2+E87BrWxpbUeDC1dkV+zbl8BMm7X0rp+reoC\nYUWMcjQMzhMmQOXUd4zwJIDPZDMdF4beq/y6WPSUTVgjs4kPDS1HT60ATnsUqyPE\n6tuGxG4j0msb4TTre87PKxMU5YPOxSiqNL0O/3u9/2shVPpjDa/uy9W+VaeBHbFm\nSQIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "tag": [], + "attachment": [] +} diff --git a/src/test/resources/snapshots/activity/testdelete.json b/src/test/resources/snapshots/activity/testdelete.json new file mode 100644 index 00000000..d7e7af23 --- /dev/null +++ b/src/test/resources/snapshots/activity/testdelete.json @@ -0,0 +1,8 @@ +{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/u/testuser#delete", + "type": "Delete", + "actor": "https://example.com/u/testuser", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": "https://example.com/u/testuser" +} -- cgit v1.2.3