diff options
author | Vitaly Takmazov | 2023-01-05 11:00:50 +0300 |
---|---|---|
committer | Vitaly Takmazov | 2023-01-05 20:58:47 +0300 |
commit | cdd03aa64548810591e043fb59a287a1b36c92ba (patch) | |
tree | 665ad1e3f1162d0be76c95a814ec4500bcf5ce55 /src/main | |
parent | 120b26c55069f89cc60ef862514d5cf09566f348 (diff) |
ActivityPub: signed GET requests, fix Signature verification
Diffstat (limited to 'src/main')
16 files changed, 407 insertions, 312 deletions
diff --git a/src/main/java/com/juick/ActivityPubManager.java b/src/main/java/com/juick/ActivityPubManager.java index a487afb1..db8a1acb 100644 --- a/src/main/java/com/juick/ActivityPubManager.java +++ b/src/main/java/com/juick/ActivityPubManager.java @@ -19,12 +19,13 @@ package com.juick; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; import com.juick.model.Message; import com.juick.model.Reaction; import com.juick.model.User; +import com.juick.service.ActivityPubService; import com.juick.service.MessagesService; import com.juick.service.SocialService; +import com.juick.service.WebfingerService; import com.juick.service.activities.*; import com.juick.service.component.NotificationListener; import com.juick.service.component.PingEvent; @@ -45,6 +46,7 @@ import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.convert.ConversionService; +import org.springframework.http.MediaType; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; @@ -62,7 +64,9 @@ import java.util.stream.Collectors; public class ActivityPubManager implements ActivityListener, NotificationListener { private static final Logger logger = LoggerFactory.getLogger("ActivityPub"); @Inject - private SignatureManager signatureManager; + private ActivityPubService activityPubService; + @Inject + private WebfingerService webfingerService; @Inject private SocialService socialService; @Inject @@ -84,12 +88,12 @@ public class ActivityPubManager implements ActivityListener, NotificationListene if (!followedUser.isAnonymous()) { // automatically accept follower requests Actor me = conversionService.convert(followedUser, Actor.class); - Actor follower = (Actor) signatureManager.getContext(URI.create(followEvent.getRequest().getActor())).get(); + Actor follower = (Actor) activityPubService.get(URI.create(followEvent.getRequest().getActor())).get(); Accept accept = new Accept(); accept.setActor(me.getId()); accept.setObject(followEvent.getRequest()); try { - signatureManager.post(me, follower, accept); + activityPubService.post(me, follower, accept); socialService.addFollower(followedUser, follower.getId()); logger.info("Follower added for {}", followedUser.getName()); } catch (IOException | NoSuchAlgorithmException e) { @@ -124,14 +128,14 @@ public class ActivityPubManager implements ActivityListener, NotificationListene Actor me = conversionService.convert(user, Actor.class); socialService.getFollowers(user).forEach(acct -> { try { - Actor follower = (Actor) signatureManager.getContext(URI.create(acct)).orElseThrow(); + Actor follower = (Actor) activityPubService.get(URI.create(acct)).orElseThrow(); Delete delete = new Delete(); delete.setId(note.getId()); delete.setActor(me.getId()); delete.setPublished(note.getPublished()); delete.setObject(note); logger.info("Deletion to follower {}", follower.getId()); - signatureManager.post(me, follower, delete); + activityPubService.post(me, follower, delete); } catch (IOException | NoSuchAlgorithmException e) { logger.warn("activitypub exception", e); } catch (NoSuchElementException ex) { @@ -196,12 +200,12 @@ public class ActivityPubManager implements ActivityListener, NotificationListene private void activityToFollowers(User user, Actor from, Activity activity) { socialService.getFollowers(user).forEach(acct -> { - var context = signatureManager.getContext(URI.create(acct)); + var context = activityPubService.get(URI.create(acct)); context.ifPresentOrElse((follower) -> { if (follower instanceof Actor) { var to = (Actor) follower; try { - signatureManager.post(from, to, activity); + activityPubService.post(from, to, activity); } catch (Exception e) { logger.warn("Delivery to {} failed: {}", to, e.getMessage()); } @@ -251,7 +255,7 @@ public class ActivityPubManager implements ActivityListener, NotificationListene subscribers.addAll(note.getCc()); subscribers.forEach(acct -> { if (!acct.equals(profileUriBuilder.followersUri(user))) { - var context = signatureManager.getContext(URI.create(acct)); + var context = activityPubService.get(URI.create(acct)); context.ifPresentOrElse((follower) -> { if (follower instanceof Actor) { var to = (Actor) follower; @@ -261,7 +265,7 @@ public class ActivityPubManager implements ActivityListener, NotificationListene create.setPublished(note.getPublished()); create.setObject(note); try { - signatureManager.post(me, to, create); + activityPubService.post(me, to, create); } catch (IOException | NoSuchAlgorithmException e) { logger.warn("Delivery to {} failed: {}", to, e.getMessage()); } @@ -308,10 +312,10 @@ public class ActivityPubManager implements ActivityListener, NotificationListene note.setTags(msg.getTags().stream().map(t -> new Hashtag(profileUriBuilder.tagUri(t), t.getName())) .collect(Collectors.toList())); if (msg.getReplyToUri() != null && msg.getReplyToUri().toASCIIString().length() > 0) { - Optional<Context> noteContext = signatureManager.getContext(msg.getReplyToUri()); + Optional<Context> noteContext = activityPubService.get(msg.getReplyToUri()); if (noteContext.isPresent()) { Note activity = (Note) noteContext.get(); - Optional<Context> personContext = signatureManager.getContext(URI.create(activity.getAttributedTo())); + Optional<Context> personContext = activityPubService.get(URI.create(activity.getAttributedTo())); if (personContext.isPresent()) { Actor person = (Actor) personContext.get(); note.getTags().add(new Mention(person.getUrl(), person.getPreferredUsername())); @@ -324,13 +328,17 @@ public class ActivityPubManager implements ActivityListener, NotificationListene } MessageUtils.getGlobalMentions(msg).forEach(m -> { // @user@server.tld -> user@server.tld - Optional<Context> personContext = signatureManager.discoverPerson(m.substring(1)); - if (personContext.isPresent()) { - Actor person = (Actor) personContext.get(); - note.getTags().add(new Mention(person.getUrl(), person.getPreferredUsername())); - List<String> cc = new ArrayList<>(note.getCc()); - cc.add(person.getId()); - note.setCc(cc); + var personURI = webfingerService.discoverAccountURI(m.substring(1), + MediaType.valueOf(Context.ACTIVITY_MEDIA_TYPE)); + if (!personURI.toASCIIString().isEmpty()) { + var personContext = activityPubService.get(personURI); + if (personContext.isPresent()) { + Actor person = (Actor) personContext.get(); + note.getTags().add(new Mention(person.getUrl(), person.getPreferredUsername())); + List<String> cc = new ArrayList<>(note.getCc()); + cc.add(person.getId()); + note.setCc(cc); + } } }); note.setSensitive(MessageUtils.isSensitive(msg)); @@ -371,7 +379,7 @@ public class ActivityPubManager implements ActivityListener, NotificationListene } public User actorToUser(URI uri) throws HttpBadRequestException, JsonProcessingException { - var context = signatureManager.getContext(uri); + var context = activityPubService.get(uri); if (context.isPresent() && context.get() instanceof Actor actor) { User user = new User(); user.setUri(URI.create(actor.getId())); diff --git a/src/main/java/com/juick/SignatureManager.java b/src/main/java/com/juick/SignatureManager.java deleted file mode 100644 index 792cc8cd..00000000 --- a/src/main/java/com/juick/SignatureManager.java +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright (C) 2008-2022, 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 <http://www.gnu.org/licenses/>. - */ - -package com.juick; - -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.Actor; -import com.juick.www.api.webfinger.model.Account; -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.cache.annotation.Cacheable; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.http.*; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; -import org.tomitribe.auth.signatures.Base64; -import org.tomitribe.auth.signatures.*; -import rocks.xmpp.addr.Jid; - -import javax.inject.Inject; -import java.io.IOException; -import java.net.URI; -import java.security.Key; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SignatureException; -import java.time.Instant; -import java.util.*; - -import static com.juick.www.api.activity.model.Context.ACTIVITY_MEDIA_TYPE; - -public class SignatureManager { - private static final Logger logger = LoggerFactory.getLogger("ActivityPub"); - @Inject - private KeystoreManager keystoreManager; - @Inject - private ObjectMapper jsonMapper; - @Inject - private UserService userService; - @Inject - private RestTemplate apClient; - @Inject - private ApplicationEventPublisher applicationEventPublisher; - - public void post(Actor from, Actor to, Context data) throws IOException, NoSuchAlgorithmException { - UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(to.getInbox()); - URI inbox = uriComponentsBuilder.build().toUri(); - Instant now = Instant.now(); - String requestDate = DateFormattersHolder.getHttpDateFormatter().format(now); - String host = inbox.getPort() > 0 ? String.format("%s:%d", inbox.getHost(), inbox.getPort()) : inbox.getHost(); - var finalContext = Context.build(data); - var payload = jsonMapper.writeValueAsString(finalContext); - final byte[] digest = MessageDigest.getInstance("SHA-256").digest(payload.getBytes()); // (1) - final String digestHeader = "SHA-256=" + new String(Base64.encodeBase64(digest)); - String signatureString = addSignature(from, host, "POST", inbox.getPath(), requestDate, digestHeader); - - HttpHeaders requestHeaders = new HttpHeaders(); - requestHeaders.add("Content-Type", Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE); - requestHeaders.add("Date", requestDate); - requestHeaders.add("Host", host); - requestHeaders.add("Digest", digestHeader); - requestHeaders.add("Signature", signatureString); - HttpEntity<String> request = new HttpEntity<>(payload, requestHeaders); - logger.debug("Sending context to {}: {}", to.getId(), payload); - ResponseEntity<Void> response = apClient.postForEntity(inbox, request, Void.class); - logger.debug("Remote response: {}", response.getStatusCode()); - } - - 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<String> 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); - Map<String, String> headers = new HashMap<>(); - headers.put("host", host); - headers.put("date", dateString); - if (StringUtils.isNotEmpty(digestHeader)) { - headers.put("digest", digestHeader); - } - Signer signer = new Signer(keystoreManager.getPrivateKey(), templateSignature); - Signature signature = signer.sign(method, path, headers); - // remove "Signature: " from result - return signature.toString().substring(10); - } - - public User verifySignature(String method, String path, Map<String, String> headers) { - String signatureString = headers.get("signature"); - Signature signature = Signature.fromString(signatureString); - var keyId = UriComponentsBuilder.fromUriString(signature.getKeyId()).fragment(null).build().toUri(); - var context = getContext(keyId); - if (context.isPresent() && context.get() instanceof Actor actor) { - Key key = KeystoreManager.publicKeyOf(actor); - if (key != null) { - Verifier verifier = new Verifier(key, signature); - try { - boolean result = verifier.verify(method, path, headers); - if (result) { - User user = new User(); - user.setUri(URI.create(actor.getId())); - if (key.equals(keystoreManager.getPublicKey())) { - 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("Verification error for {}: {}", signature.getKeyId(), e.getMessage()); - } - } else { - logger.warn("Public key missing for {}", actor.getId()); - } - } else { - logger.warn("Public key error for {}", signature.getKeyId()); - } - return AnonymousUser.INSTANCE; - } - - @Cacheable("profiles") - public Optional<Context> getContext(URI contextUri) { - try { - HttpHeaders headers = new HttpHeaders(); - headers.setAccept(Collections.singletonList(MediaType.valueOf(ACTIVITY_MEDIA_TYPE))); - HttpEntity<Void> activityRequest = new HttpEntity<>(headers); - var response = apClient.exchange(contextUri, HttpMethod.GET, activityRequest, Context.class); - if (response.getStatusCode().is2xxSuccessful() && response.hasBody()) { - var context = response.getBody(); - if (context == null) { - logger.warn("Cannot identify {}", contextUri); - return Optional.empty(); - } - return Optional.of(context); - } - } catch (Exception e) { - logger.warn("{}", e.getMessage()); - } - return Optional.empty(); - } - - public Optional<Context> discoverPerson(String acct) { - Jid acctId = Jid.of(acct); - URI resourceUri = UriComponentsBuilder.fromPath("/.well-known/webfinger").host(acctId.getDomain()) - .scheme("https").queryParam("resource", "acct:" + acct).build().toUri(); - HttpHeaders headers = new HttpHeaders(); - headers.setAccept(Collections.singletonList(MediaType.valueOf("application/jrd+json"))); - HttpEntity<Void> webfingerRequest = new HttpEntity<>(headers); - try { - ResponseEntity<Account> response = apClient.exchange(resourceUri, HttpMethod.GET, webfingerRequest, - Account.class); - if (response.getStatusCode().is2xxSuccessful()) { - Account acctData = response.getBody(); - if (acctData != null) { - for (Link l : acctData.links()) { - if (l.rel().equals("self") && l.type().equals(ACTIVITY_MEDIA_TYPE)) { - return getContext(URI.create(l.href())); - } - } - } - } - } catch (RestClientException e) { - logger.warn("Cannot discover person {}: {}", acct, e.getMessage()); - return Optional.empty(); - } - return Optional.empty(); - } -} diff --git a/src/main/java/com/juick/config/ActivityPubClientConfig.java b/src/main/java/com/juick/config/ActivityPubClientConfig.java new file mode 100644 index 00000000..940071e7 --- /dev/null +++ b/src/main/java/com/juick/config/ActivityPubClientConfig.java @@ -0,0 +1,61 @@ +package com.juick.config; + +import java.nio.charset.StandardCharsets; + +import javax.inject.Inject; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juick.model.User; +import com.juick.service.SignatureService; +import com.juick.util.ActivityPubRequestInterceptor; +import com.juick.www.api.activity.model.objects.Actor; + +@Configuration +public class ActivityPubClientConfig { + @Inject + CloseableHttpClient httpClient; + @Inject + ObjectMapper jsonMapper; + @Inject + private ActivityPubClientErrorHandler activityPubClientErrorHandler; + @Inject + private SignatureService signatureService; + @Inject + private ConversionService conversionService; + @Inject + private User serviceUser; + + @Bean + ClientHttpRequestFactory clientHttpRequestFactory() { + var clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory(); + clientHttpRequestFactory.setHttpClient(httpClient); + return clientHttpRequestFactory; + } + + @Bean + MappingJackson2HttpMessageConverter mappingJacksonHttpMessageConverter() { + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + converter.setObjectMapper(jsonMapper); + return converter; + } + + @Bean + RestTemplate restClient() { + RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory()); + restTemplate.getMessageConverters().add(0, mappingJacksonHttpMessageConverter()); + restTemplate.getMessageConverters().add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); + restTemplate.setErrorHandler(activityPubClientErrorHandler); + restTemplate.getInterceptors().add(new ActivityPubRequestInterceptor()); + return restTemplate; + } +} diff --git a/src/main/java/com/juick/config/ActivityPubClientErrorHandler.java b/src/main/java/com/juick/config/ActivityPubClientErrorHandler.java index 243a4e32..b3aedf1f 100644 --- a/src/main/java/com/juick/config/ActivityPubClientErrorHandler.java +++ b/src/main/java/com/juick/config/ActivityPubClientErrorHandler.java @@ -18,7 +18,6 @@ package com.juick.config; import com.juick.service.activities.DeleteUserEvent; -import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; diff --git a/src/main/java/com/juick/config/ActivityPubConfig.java b/src/main/java/com/juick/config/ActivityPubConfig.java index 9a626d12..b18dc71c 100644 --- a/src/main/java/com/juick/config/ActivityPubConfig.java +++ b/src/main/java/com/juick/config/ActivityPubConfig.java @@ -20,29 +20,18 @@ package com.juick.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.juick.ActivityPubManager; import com.juick.KeystoreManager; -import com.juick.util.ActivityPubRequestInterceptor; import com.juick.www.WebApp; import com.juick.www.api.activity.converters.UserToActorConverter; import com.juick.www.api.activity.helpers.ProfileUriBuilder; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.http.converter.StringHttpMessageConverter; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.web.client.RestTemplate; import javax.inject.Inject; -import java.nio.charset.StandardCharsets; - @Configuration public class ActivityPubConfig { @Inject - ActivityPubClientErrorHandler activityPubClientErrorHandler; - @Inject ObjectMapper jsonMapper; @Inject KeystoreManager keystoreManager; @@ -52,37 +41,10 @@ public class ActivityPubConfig { private String baseUri; @Bean - MappingJackson2HttpMessageConverter mappingJacksonHttpMessageConverter() { - MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); - converter.setObjectMapper(jsonMapper); - return converter; - } - - @Bean ActivityPubManager activityPubManager() { return new ActivityPubManager(); } - @Inject - CloseableHttpClient httpClient; - - @Bean - ClientHttpRequestFactory clientHttpRequestFactory() { - var clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory(); - clientHttpRequestFactory.setHttpClient(httpClient); - return clientHttpRequestFactory; - } - - @Bean - RestTemplate apClient() { - RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory()); - restTemplate.getMessageConverters().add(0, mappingJacksonHttpMessageConverter()); - restTemplate.getMessageConverters().add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); - restTemplate.setErrorHandler(activityPubClientErrorHandler); - restTemplate.getInterceptors().add(new ActivityPubRequestInterceptor()); - return restTemplate; - } - @Bean UserToActorConverter userToActorConverter() { return new UserToActorConverter(profileUriBuilder(), keystoreManager, webApp); diff --git a/src/main/java/com/juick/config/AppConfig.java b/src/main/java/com/juick/config/AppConfig.java index d54bc675..ff92de1f 100644 --- a/src/main/java/com/juick/config/AppConfig.java +++ b/src/main/java/com/juick/config/AppConfig.java @@ -20,6 +20,7 @@ package com.juick.config; import com.juick.*; import com.juick.model.User; import com.juick.service.HelpService; +import com.juick.service.SignatureService; import com.juick.service.StorageService; import com.juick.service.FileSystemStorageService; import com.juick.service.UserService; @@ -158,8 +159,8 @@ public class AppConfig { } @Bean - SignatureManager signatureManager() { - return new SignatureManager(); + SignatureService signatureManager() { + return new SignatureService(keystoreManager()); } @Bean diff --git a/src/main/java/com/juick/config/SecurityConfig.java b/src/main/java/com/juick/config/SecurityConfig.java index d60abe00..d3f89eef 100644 --- a/src/main/java/com/juick/config/SecurityConfig.java +++ b/src/main/java/com/juick/config/SecurityConfig.java @@ -18,7 +18,7 @@ package com.juick.config; import com.juick.KeystoreManager; -import com.juick.SignatureManager; +import com.juick.service.ActivityPubService; import com.juick.service.UserService; import com.juick.service.security.BearerTokenAuthenticationFilter; import com.juick.service.security.HTTPSignatureAuthenticationFilter; @@ -30,8 +30,6 @@ import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -53,7 +51,6 @@ import org.springframework.security.oauth2.server.authorization.config.annotatio import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.*; import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices; @@ -65,7 +62,6 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import javax.inject.Inject; -import java.io.IOException; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.util.Arrays; @@ -107,7 +103,7 @@ public class SecurityConfig { } @Inject - private SignatureManager signatureManager; + private ActivityPubService activityPubService; @Bean HashParamAuthenticationFilter apiAuthenticationFilter() { @@ -195,11 +191,11 @@ public class SecurityConfig { } @Bean - @Order(2) + @Order(Ordered.HIGHEST_PRECEDENCE + 1) SecurityFilterChain apiChain(HttpSecurity http) throws Exception { - http.securityMatcher("/api/**") + http.securityMatcher("/api/**", "/u/**", "/n/**") .addFilterBefore(apiAuthenticationFilter(), BasicAuthenticationFilter.class) - .addFilterBefore(new HTTPSignatureAuthenticationFilter(signatureManager, userService), + .addFilterBefore(new HTTPSignatureAuthenticationFilter(activityPubService, userService), BasicAuthenticationFilter.class) .authorizeHttpRequests(requests -> requests .requestMatchers(HttpMethod.OPTIONS).permitAll() @@ -210,7 +206,7 @@ public class SecurityConfig { "/api/skypebotendpoint", "/api/_fblogin", "/api/_vklogin", "/api/_tglogin", "/api/_google", "/api/_applelogin", "/api/signup", - "/api/inbox", "/api/events", "/api/u/", + "/api/inbox", "/api/events", "/u/**", "/n/**", "/api/info/**", "/api/v1/apps", "/api/v1/instance", "/api/nodeinfo/2.0", "/oauth/**") .permitAll() @@ -251,6 +247,7 @@ public class SecurityConfig { return handler; } @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 2) SecurityFilterChain wwwChain(HttpSecurity http) throws Exception { http.addFilterBefore(wwwAuthenticationFilter(), BasicAuthenticationFilter.class) .authorizeHttpRequests(authorize -> authorize diff --git a/src/main/java/com/juick/service/ActivityPubService.java b/src/main/java/com/juick/service/ActivityPubService.java index c45b0c13..f161be7b 100644 --- a/src/main/java/com/juick/service/ActivityPubService.java +++ b/src/main/java/com/juick/service/ActivityPubService.java @@ -18,24 +18,71 @@ package com.juick.service; import com.juick.model.User; +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.Actor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juick.KeystoreManager; import com.juick.model.AnonymousUser; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.convert.ConversionService; import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.*; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; +import org.tomitribe.auth.signatures.MissingRequiredHeaderException; +import org.tomitribe.auth.signatures.Signature; +import org.tomitribe.auth.signatures.Verifier; import javax.annotation.Nonnull; import javax.inject.Inject; + +import java.io.IOException; +import java.net.URI; +import java.security.Key; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.time.Instant; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Optional; @Repository public class ActivityPubService extends BaseJdbcService implements SocialService { + + private static final Logger logger = LoggerFactory.getLogger("ActivityPub"); + @Value("${ap_base_uri:http://localhost:8080/}") private String baseUri; @Inject private UserService userService; + @Inject + private RestTemplate restClient; + @Inject + private ObjectMapper jsonMapper; + @Inject + private SignatureService signatureService; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + @Inject + private KeystoreManager keystoreManager; + @Inject + private User serviceUser; + @Inject + private ConversionService conversionService; @Transactional(readOnly = true) @Override @@ -53,7 +100,8 @@ public class ActivityPubService extends BaseJdbcService implements SocialService @Transactional(readOnly = true) @Override public @Nonnull List<String> getFollowers(User user) { - return getJdbcTemplate().queryForList("SELECT acct FROM followers WHERE user_id=?", String.class, user.getUid()); + return getJdbcTemplate().queryForList("SELECT acct FROM followers WHERE user_id=?", String.class, + user.getUid()); } @Transactional @@ -78,4 +126,112 @@ public class ActivityPubService extends BaseJdbcService implements SocialService public void removeAccount(String acct) { getJdbcTemplate().update("DELETE FROM followers WHERE acct=?", acct); } + + @Cacheable("profiles") + public Optional<Context> get(URI contextUri) { + Instant now = Instant.now(); + String requestDate = DateFormattersHolder.getHttpDateFormatter().format(now); + String host = contextUri.getPort() > 0 ? String.format("%s:%d", contextUri.getHost(), contextUri.getPort()) + : contextUri.getHost(); + var from = conversionService.convert(serviceUser, Actor.class); + try { + String signatureString = signatureService.addSignature(from, host, "get", contextUri.getPath(), requestDate, + ""); + HttpHeaders requestHeaders = new HttpHeaders(); + requestHeaders.add("Date", requestDate); + requestHeaders.add("Host", host); + requestHeaders.add("Signature", signatureString); + requestHeaders.setAccept(Collections.singletonList(MediaType.valueOf(Context.ACTIVITY_MEDIA_TYPE))); + HttpEntity<Void> activityRequest = new HttpEntity<>(requestHeaders); + var response = restClient.exchange(contextUri, HttpMethod.GET, activityRequest, Context.class); + if (response.getStatusCode().is2xxSuccessful()) { + var context = response.getBody(); + if (context == null) { + logger.warn("Cannot identify {}", contextUri); + return Optional.empty(); + } + return Optional.of(context); + } + } catch (IOException e) { + logger.warn("HTTP Signature exception: {}", e.getMessage()); + } + return Optional.empty(); + } + + public HttpStatusCode post(Actor from, Actor to, Context data) throws IOException, NoSuchAlgorithmException { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(to.getInbox()); + URI inbox = uriComponentsBuilder.build().toUri(); + Instant now = Instant.now(); + String requestDate = DateFormattersHolder.getHttpDateFormatter().format(now); + String host = inbox.getPort() > 0 ? String.format("%s:%d", inbox.getHost(), inbox.getPort()) : inbox.getHost(); + var finalContext = Context.build(data); + var payload = jsonMapper.writeValueAsString(finalContext); + final byte[] digest = MessageDigest.getInstance("SHA-256").digest(payload.getBytes()); // (1) + final String digestHeader = "SHA-256=" + new String(Base64.encodeBase64(digest)); + String signatureString = signatureService.addSignature(from, host, "post", inbox.getPath(), requestDate, + digestHeader); + + HttpHeaders requestHeaders = new HttpHeaders(); + requestHeaders.add("Content-Type", Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE); + requestHeaders.add("Date", requestDate); + requestHeaders.add("Host", host); + requestHeaders.add("Digest", digestHeader); + requestHeaders.add("Signature", signatureString); + HttpEntity<String> request = new HttpEntity<>(payload, requestHeaders); + logger.debug("Sending context to {}: {}", to.getId(), payload); + ResponseEntity<String> response = restClient.postForEntity(inbox, request, String.class); + logger.debug("Remote response: {} {}", response.getStatusCode(), response.getBody()); + return response.getStatusCode(); + } + + public User verifyActor(String method, String path, Map<String, String> headers) { + String signatureString = headers.get("signature"); + if (StringUtils.isNotEmpty(signatureString)) { + try { + Signature signature = Signature.fromString(signatureString); + var keyId = UriComponentsBuilder.fromUriString(signature.getKeyId()).fragment(null).build().toUri(); + var user = getUserByAccountUri(keyId.toASCIIString()); + Key key = null; + Actor actor = null; + if (!user.isAnonymous()) { + // local user + key = keystoreManager.getPublicKey(); + } else { + var context = get(keyId); + if (context.isPresent()) { + actor = (Actor) context.get(); + key = KeystoreManager.publicKeyOf(actor); + } + } + if (key != null) { + Verifier verifier = new Verifier(key, signature); + try { + boolean result = verifier.verify(method.toLowerCase(), path, headers); + if (result) { + if (!user.isAnonymous()) { + return user; + } else { + if (actor != null) { + User person = new User(); + person.setUri(URI.create(actor.getId())); + if (actor.isSuspended()) { + logger.info("{} is suspended, deleting", actor.getId()); + applicationEventPublisher + .publishEvent(new DeleteUserEvent(this, actor.getId())); + } + return person; + } + } + } + } catch (NoSuchAlgorithmException | SignatureException | MissingRequiredHeaderException + | IOException e) { + logger.warn("Verification error for {}: {}", signature.getKeyId(), e.getMessage()); + } + } + } catch (Exception ex) { + + } + } + return AnonymousUser.INSTANCE; + } } diff --git a/src/main/java/com/juick/service/SignatureService.java b/src/main/java/com/juick/service/SignatureService.java new file mode 100644 index 00000000..12d9d67b --- /dev/null +++ b/src/main/java/com/juick/service/SignatureService.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2008-2022, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.service; + +import com.juick.KeystoreManager; +import com.juick.www.api.activity.model.objects.Actor; +import org.apache.commons.lang3.StringUtils; +import org.tomitribe.auth.signatures.*; +import java.io.IOException; +import java.util.*; + +public class SignatureService { + + private KeystoreManager keystoreManager; + + public SignatureService(KeystoreManager keystoreManager) { + this.keystoreManager = 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<String> 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); + Map<String, String> headers = new HashMap<>(); + headers.put("host", host); + headers.put("date", dateString); + if (StringUtils.isNotEmpty(digestHeader)) { + headers.put("digest", digestHeader); + } + Signer signer = new Signer(keystoreManager.getPrivateKey(), templateSignature); + Signature signature = signer.sign(method.toLowerCase(), path, headers); + // remove "Signature: " from result + return signature.toString().substring(10); + } +} diff --git a/src/main/java/com/juick/service/WebfingerService.java b/src/main/java/com/juick/service/WebfingerService.java new file mode 100644 index 00000000..dc978763 --- /dev/null +++ b/src/main/java/com/juick/service/WebfingerService.java @@ -0,0 +1,58 @@ +package com.juick.service; + +import java.net.URI; +import java.util.Collections; + +import javax.inject.Inject; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import com.juick.www.api.webfinger.model.Account; +import com.juick.www.api.webfinger.model.Link; + +import rocks.xmpp.addr.Jid; + +@Component +public class WebfingerService { + private static final Logger logger = LoggerFactory.getLogger("ActivityPub"); + + private final RestTemplate restClient; + @Inject + public WebfingerService(final RestTemplate restClient) { + this.restClient = restClient; + } + public URI discoverAccountURI(String acct, MediaType linkType) { + Jid acctId = Jid.of(acct); + URI resourceUri = UriComponentsBuilder.fromPath("/.well-known/webfinger").host(acctId.getDomain()) + .scheme("https").queryParam("resource", "acct:" + acct).build().toUri(); + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.valueOf("application/jrd+json"))); + HttpEntity<Void> webfingerRequest = new HttpEntity<>(headers); + try { + ResponseEntity<Account> response = restClient.exchange(resourceUri, HttpMethod.GET, webfingerRequest, + Account.class); + if (response.getStatusCode().is2xxSuccessful()) { + var account = response.getBody(); + for (Link l : account.links()) { + if (l.rel().equals("self") && l.type().equals(linkType.toString())) { + return URI.create(l.href()); + } + } + } + } catch (RestClientException e) { + logger.warn("Cannot discover person {}: {}", acct, e.getMessage()); + } + return URI.create(StringUtils.EMPTY); + } +} diff --git a/src/main/java/com/juick/service/security/BearerTokenAuthenticationFilter.java b/src/main/java/com/juick/service/security/BearerTokenAuthenticationFilter.java index 2e96a594..f4e73b12 100644 --- a/src/main/java/com/juick/service/security/BearerTokenAuthenticationFilter.java +++ b/src/main/java/com/juick/service/security/BearerTokenAuthenticationFilter.java @@ -19,7 +19,6 @@ package com.juick.service.security; import com.juick.service.UserService; import com.juick.service.security.entities.JuickUser; -import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import jakarta.servlet.FilterChain; @@ -52,9 +51,9 @@ public class BearerTokenAuthenticationFilter extends BaseAuthenticationFilter { } @Override - protected void doFilterInternal(@Nonnull HttpServletRequest request, - @Nonnull HttpServletResponse response, - @Nonnull FilterChain filterChain) throws ServletException, IOException { + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { if (authenticationIsRequired()) { var headers = Collections.list(request.getHeaderNames()) .stream() diff --git a/src/main/java/com/juick/service/security/HTTPSignatureAuthenticationFilter.java b/src/main/java/com/juick/service/security/HTTPSignatureAuthenticationFilter.java index 5f6a730e..a851ef36 100644 --- a/src/main/java/com/juick/service/security/HTTPSignatureAuthenticationFilter.java +++ b/src/main/java/com/juick/service/security/HTTPSignatureAuthenticationFilter.java @@ -17,8 +17,8 @@ package com.juick.service.security; -import com.juick.SignatureManager; import com.juick.model.User; +import com.juick.service.ActivityPubService; import com.juick.service.UserService; import com.juick.service.security.entities.JuickUser; import jakarta.servlet.FilterChain; @@ -31,7 +31,6 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import javax.annotation.Nonnull; import java.io.IOException; import java.util.Collections; import java.util.Map; @@ -39,39 +38,37 @@ import java.util.stream.Collectors; public class HTTPSignatureAuthenticationFilter extends BaseAuthenticationFilter { - private final SignatureManager signatureManager; + private final ActivityPubService signatureManager; private final UserService userService; public HTTPSignatureAuthenticationFilter( - final SignatureManager signatureManager, + final ActivityPubService activityPubService, final UserService userService) { - this.signatureManager = signatureManager; + this.signatureManager = activityPubService; this.userService = userService; } @Override - protected void doFilterInternal(@Nonnull HttpServletRequest request, @Nonnull HttpServletResponse response, - @Nonnull FilterChain filterChain) throws IOException, ServletException { + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws IOException, ServletException { if (authenticationIsRequired()) { Map<String, String> headers = Collections.list(request.getHeaderNames()) .stream() .collect(Collectors.toMap(String::toLowerCase, request::getHeader)); - if (StringUtils.isNotEmpty(headers.get("signature"))) { - User user = signatureManager.verifySignature(request.getMethod(), request.getRequestURI(), headers); - String userUri = user.getUri().toString(); - if (!user.isAnonymous() || userUri.length() > 0) { - if (userUri.length() == 0) { - User userWithPassword = userService.getUserByName(user.getName()); - userWithPassword.setAuthHash(userService.getHashByUID(userWithPassword.getUid())); - Authentication authentication = new UsernamePasswordAuthenticationToken( - new JuickUser(user), userWithPassword.getCredentials(), JuickUser.USER_AUTHORITY); - SecurityContextHolder.getContext().setAuthentication(authentication); - } else { - // anonymous must have with uri - Authentication authentication = new AnonymousAuthenticationToken(userUri, - new JuickUser(user), JuickUser.ANONYMOUS_AUTHORITY); - SecurityContextHolder.getContext().setAuthentication(authentication); - } + var user = signatureManager.verifyActor(request.getMethod(), request.getRequestURI(), headers); + String userUri = user.getUri().toString(); + if (!user.isAnonymous() || userUri.length() > 0) { + if (userUri.length() == 0) { + User userWithPassword = userService.getUserByName(user.getName()); + userWithPassword.setAuthHash(userService.getHashByUID(userWithPassword.getUid())); + Authentication authentication = new UsernamePasswordAuthenticationToken( + new JuickUser(user), userWithPassword.getCredentials(), JuickUser.USER_AUTHORITY); + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + // anonymous must have with uri + Authentication authentication = new AnonymousAuthenticationToken(userUri, + new JuickUser(user), JuickUser.ANONYMOUS_AUTHORITY); + SecurityContextHolder.getContext().setAuthentication(authentication); } } } diff --git a/src/main/java/com/juick/util/ActivityPubRequestInterceptor.java b/src/main/java/com/juick/util/ActivityPubRequestInterceptor.java index 1876cc90..138a3d09 100644 --- a/src/main/java/com/juick/util/ActivityPubRequestInterceptor.java +++ b/src/main/java/com/juick/util/ActivityPubRequestInterceptor.java @@ -30,7 +30,7 @@ public class ActivityPubRequestInterceptor implements ClientHttpRequestIntercept @Override public @NonNull ClientHttpResponse intercept(HttpRequest request, @NonNull byte[] body, - ClientHttpRequestExecution execution) throws IOException { + ClientHttpRequestExecution execution) throws IOException { request.getHeaders().set(HttpHeaders.USER_AGENT, "Juick/2.x"); return execution.execute(request, body); } diff --git a/src/main/java/com/juick/www/api/Users.java b/src/main/java/com/juick/www/api/Users.java index f7c24d8d..124632d0 100644 --- a/src/main/java/com/juick/www/api/Users.java +++ b/src/main/java/com/juick/www/api/Users.java @@ -72,11 +72,6 @@ public class Users { @Inject private ApplicationEventPublisher applicationEventPublisher; - @RequestMapping(value = "/api/auth", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) - public String getAuthToken(@ModelAttribute User visitor) { - return userService.getHashByUID(visitor.getUid()); - } - @RequestMapping(value = "/api/users", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) public List<User> doGetUsers( @ModelAttribute User visitor, @@ -105,7 +100,7 @@ public class Users { SecureUser me = new SecureUser(); me.setUid(visitor.getUid()); me.setName(visitor.getName()); - me.setAuthHash(getAuthToken(visitor)); + me.setAuthHash(userService.getHashByUID(visitor.getUid())); List<Integer> unread = messagesService.getUnread(visitor); me.setUnread(unread); me.setUnreadCount(unread.size()); diff --git a/src/main/java/com/juick/www/controllers/Compat.java b/src/main/java/com/juick/www/controllers/Compat.java index 936a8e5c..300a0855 100644 --- a/src/main/java/com/juick/www/controllers/Compat.java +++ b/src/main/java/com/juick/www/controllers/Compat.java @@ -17,8 +17,11 @@ package com.juick.www.controllers; -import com.juick.SignatureManager; +import com.juick.service.ActivityPubService; +import com.juick.service.WebfingerService; import com.juick.util.HttpNotFoundException; +import com.juick.www.api.activity.model.Context; +import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -30,7 +33,9 @@ import javax.inject.Inject; @Controller public class Compat { @Inject - private SignatureManager signatureManager; + private WebfingerService webfingerService; + @Inject + private ActivityPubService activityPubService; @GetMapping("/share") public RedirectView share(@RequestParam String text, RedirectAttributes attributes) { attributes.addAttribute("body", text); @@ -38,9 +43,10 @@ public class Compat { } @GetMapping("/mention") public RedirectView mention(@RequestParam String username) { - var profile = signatureManager.discoverPerson(username); - if (profile.isPresent()) { - return new RedirectView(profile.get().getUrl()); + var uri = webfingerService.discoverAccountURI(username, MediaType.valueOf(Context.ACTIVITY_MEDIA_TYPE)); + if (!uri.toASCIIString().isEmpty()) { + var context = activityPubService.get(uri).orElseThrow(HttpNotFoundException::new); + return new RedirectView(context.getUrl()); } throw new HttpNotFoundException(); } diff --git a/src/main/java/com/mitchellbosecke/pebble/extension/filters/TagsListFilter.java b/src/main/java/com/mitchellbosecke/pebble/extension/filters/TagsListFilter.java index 14faaa5d..bf4b72fd 100644 --- a/src/main/java/com/mitchellbosecke/pebble/extension/filters/TagsListFilter.java +++ b/src/main/java/com/mitchellbosecke/pebble/extension/filters/TagsListFilter.java @@ -25,7 +25,6 @@ import io.pebbletemplates.pebble.template.PebbleTemplate; import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; /** * Created by vitalyster on 23.05.2017. |