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/java/com/juick/service | |
parent | 120b26c55069f89cc60ef862514d5cf09566f348 (diff) |
ActivityPub: signed GET requests, fix Signature verification
Diffstat (limited to 'src/main/java/com/juick/service')
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); } } } |