aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/com/juick/service
diff options
context:
space:
mode:
authorGravatar Vitaly Takmazov2023-01-05 11:00:50 +0300
committerGravatar Vitaly Takmazov2023-01-05 20:58:47 +0300
commitcdd03aa64548810591e043fb59a287a1b36c92ba (patch)
tree665ad1e3f1162d0be76c95a814ec4500bcf5ce55 /src/main/java/com/juick/service
parent120b26c55069f89cc60ef862514d5cf09566f348 (diff)
ActivityPub: signed GET requests, fix Signature verification
Diffstat (limited to 'src/main/java/com/juick/service')
-rw-r--r--src/main/java/com/juick/service/ActivityPubService.java158
-rw-r--r--src/main/java/com/juick/service/SignatureService.java57
-rw-r--r--src/main/java/com/juick/service/WebfingerService.java58
-rw-r--r--src/main/java/com/juick/service/security/BearerTokenAuthenticationFilter.java7
-rw-r--r--src/main/java/com/juick/service/security/HTTPSignatureAuthenticationFilter.java43
5 files changed, 295 insertions, 28 deletions
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);
}
}
}