Break out into a multi-module project

This commit is contained in:
Moxie Marlinspike
2019-04-20 21:56:20 -07:00
parent b41dde777e
commit d0d375aeb7
318 changed files with 255 additions and 215 deletions

View File

@@ -0,0 +1,516 @@
/*
* Copyright (C) 2013 Open WhisperSystems
*
* 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 org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.annotation.Timed;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.auth.AuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException;
import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.DeviceName;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
import org.whispersystems.textsecuregcm.sms.SmsSender;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.storage.AbusiveHostRule;
import org.whispersystems.textsecuregcm.storage.AbusiveHostRules;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PendingAccountsManager;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.util.VerificationCode;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.auth.Auth;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/accounts")
public class AccountController {
private final Logger logger = LoggerFactory.getLogger(AccountController.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter newUserMeter = metricRegistry.meter(name(AccountController.class, "brand_new_user" ));
private final Meter blockedHostMeter = metricRegistry.meter(name(AccountController.class, "blocked_host" ));
private final Meter filteredHostMeter = metricRegistry.meter(name(AccountController.class, "filtered_host" ));
private final Meter rateLimitedHostMeter = metricRegistry.meter(name(AccountController.class, "rate_limited_host" ));
private final Meter rateLimitedPrefixMeter = metricRegistry.meter(name(AccountController.class, "rate_limited_prefix"));
private final Meter captchaSuccessMeter = metricRegistry.meter(name(AccountController.class, "captcha_success" ));
private final Meter captchaFailureMeter = metricRegistry.meter(name(AccountController.class, "captcha_failure" ));
private final PendingAccountsManager pendingAccounts;
private final AccountsManager accounts;
private final AbusiveHostRules abusiveHostRules;
private final RateLimiters rateLimiters;
private final SmsSender smsSender;
private final DirectoryQueue directoryQueue;
private final MessagesManager messagesManager;
private final TurnTokenGenerator turnTokenGenerator;
private final Map<String, Integer> testDevices;
private final RecaptchaClient recaptchaClient;
public AccountController(PendingAccountsManager pendingAccounts,
AccountsManager accounts,
AbusiveHostRules abusiveHostRules,
RateLimiters rateLimiters,
SmsSender smsSenderFactory,
DirectoryQueue directoryQueue,
MessagesManager messagesManager,
TurnTokenGenerator turnTokenGenerator,
Map<String, Integer> testDevices,
RecaptchaClient recaptchaClient)
{
this.pendingAccounts = pendingAccounts;
this.accounts = accounts;
this.abusiveHostRules = abusiveHostRules;
this.rateLimiters = rateLimiters;
this.smsSender = smsSenderFactory;
this.directoryQueue = directoryQueue;
this.messagesManager = messagesManager;
this.testDevices = testDevices;
this.turnTokenGenerator = turnTokenGenerator;
this.recaptchaClient = recaptchaClient;
}
@Timed
@GET
@Path("/{transport}/code/{number}")
public Response createAccount(@PathParam("transport") String transport,
@PathParam("number") String number,
@HeaderParam("X-Forwarded-For") String forwardedFor,
@HeaderParam("Accept-Language") Optional<String> locale,
@QueryParam("client") Optional<String> client,
@QueryParam("captcha") Optional<String> captcha)
throws RateLimitExceededException
{
if (!Util.isValidNumber(number)) {
logger.info("Invalid number: " + number);
throw new WebApplicationException(Response.status(400).build());
}
String requester = Arrays.stream(forwardedFor.split(","))
.map(String::trim)
.reduce((a, b) -> b)
.orElseThrow();
CaptchaRequirement requirement = requiresCaptcha(number, transport, forwardedFor, requester, captcha);
if (requirement.isCaptchaRequired()) {
if (requirement.isAutoBlock() && shouldAutoBlock(requester)) {
logger.info("Auto-block: " + requester);
abusiveHostRules.setBlockedHost(requester, "Auto-Block");
}
return Response.status(402).build();
}
switch (transport) {
case "sms":
rateLimiters.getSmsDestinationLimiter().validate(number);
break;
case "voice":
rateLimiters.getVoiceDestinationLimiter().validate(number);
rateLimiters.getVoiceDestinationDailyLimiter().validate(number);
break;
default:
throw new WebApplicationException(Response.status(422).build());
}
VerificationCode verificationCode = generateVerificationCode(number);
StoredVerificationCode storedVerificationCode = new StoredVerificationCode(verificationCode.getVerificationCode(),
System.currentTimeMillis());
pendingAccounts.store(number, storedVerificationCode);
if (testDevices.containsKey(number)) {
// noop
} else if (transport.equals("sms")) {
smsSender.deliverSmsVerification(number, client, verificationCode.getVerificationCodeDisplay());
} else if (transport.equals("voice")) {
smsSender.deliverVoxVerification(number, verificationCode.getVerificationCode(), locale);
}
metricRegistry.meter(name(AccountController.class, "create", Util.getCountryCode(number))).mark();
return Response.ok().build();
}
@Timed
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Path("/code/{verification_code}")
public void verifyAccount(@PathParam("verification_code") String verificationCode,
@HeaderParam("Authorization") String authorizationHeader,
@HeaderParam("X-Signal-Agent") String userAgent,
@Valid AccountAttributes accountAttributes)
throws RateLimitExceededException
{
try {
AuthorizationHeader header = AuthorizationHeader.fromFullHeader(authorizationHeader);
String number = header.getNumber();
String password = header.getPassword();
rateLimiters.getVerifyLimiter().validate(number);
Optional<StoredVerificationCode> storedVerificationCode = pendingAccounts.getCodeForNumber(number);
if (!storedVerificationCode.isPresent() || !storedVerificationCode.get().isValid(verificationCode)) {
throw new WebApplicationException(Response.status(403).build());
}
Optional<Account> existingAccount = accounts.get(number);
if (existingAccount.isPresent() &&
existingAccount.get().getPin().isPresent() &&
System.currentTimeMillis() - existingAccount.get().getLastSeen() < TimeUnit.DAYS.toMillis(7))
{
rateLimiters.getVerifyLimiter().clear(number);
long timeRemaining = TimeUnit.DAYS.toMillis(7) - (System.currentTimeMillis() - existingAccount.get().getLastSeen());
if (accountAttributes.getPin() == null) {
throw new WebApplicationException(Response.status(423)
.entity(new RegistrationLockFailure(timeRemaining))
.build());
}
rateLimiters.getPinLimiter().validate(number);
if (!MessageDigest.isEqual(existingAccount.get().getPin().get().getBytes(), accountAttributes.getPin().getBytes())) {
throw new WebApplicationException(Response.status(423)
.entity(new RegistrationLockFailure(timeRemaining))
.build());
}
rateLimiters.getPinLimiter().clear(number);
}
createAccount(number, password, userAgent, accountAttributes);
metricRegistry.meter(name(AccountController.class, "verify", Util.getCountryCode(number))).mark();
} catch (InvalidAuthorizationHeaderException e) {
logger.info("Bad Authorization Header", e);
throw new WebApplicationException(Response.status(401).build());
}
}
@Timed
@GET
@Path("/turn/")
@Produces(MediaType.APPLICATION_JSON)
public TurnToken getTurnToken(@Auth Account account) throws RateLimitExceededException {
rateLimiters.getTurnLimiter().validate(account.getNumber());
return turnTokenGenerator.generate();
}
@Timed
@PUT
@Path("/gcm/")
@Consumes(MediaType.APPLICATION_JSON)
public void setGcmRegistrationId(@Auth Account account, @Valid GcmRegistrationId registrationId) {
Device device = account.getAuthenticatedDevice().get();
boolean wasAccountActive = account.isActive();
if (device.getGcmId() != null &&
device.getGcmId().equals(registrationId.getGcmRegistrationId()))
{
return;
}
device.setApnId(null);
device.setVoipApnId(null);
device.setGcmId(registrationId.getGcmRegistrationId());
device.setFetchesMessages(false);
accounts.update(account);
if (!wasAccountActive && account.isActive()) {
directoryQueue.addRegisteredUser(account.getNumber());
}
}
@Timed
@DELETE
@Path("/gcm/")
public void deleteGcmRegistrationId(@Auth Account account) {
Device device = account.getAuthenticatedDevice().get();
device.setGcmId(null);
device.setFetchesMessages(false);
accounts.update(account);
if (!account.isActive()) {
directoryQueue.deleteRegisteredUser(account.getNumber());
}
}
@Timed
@PUT
@Path("/apn/")
@Consumes(MediaType.APPLICATION_JSON)
public void setApnRegistrationId(@Auth Account account, @Valid ApnRegistrationId registrationId) {
Device device = account.getAuthenticatedDevice().get();
boolean wasAccountActive = account.isActive();
device.setApnId(registrationId.getApnRegistrationId());
device.setVoipApnId(registrationId.getVoipRegistrationId());
device.setGcmId(null);
device.setFetchesMessages(false);
accounts.update(account);
if (!wasAccountActive && account.isActive()) {
directoryQueue.addRegisteredUser(account.getNumber());
}
}
@Timed
@DELETE
@Path("/apn/")
public void deleteApnRegistrationId(@Auth Account account) {
Device device = account.getAuthenticatedDevice().get();
device.setApnId(null);
device.setFetchesMessages(false);
accounts.update(account);
if (!account.isActive()) {
directoryQueue.deleteRegisteredUser(account.getNumber());
}
}
@Timed
@PUT
@Produces(MediaType.APPLICATION_JSON)
@Path("/pin/")
public void setPin(@Auth Account account, @Valid RegistrationLock accountLock) {
account.setPin(accountLock.getPin());
accounts.update(account);
}
@Timed
@DELETE
@Path("/pin/")
public void removePin(@Auth Account account) {
account.setPin(null);
accounts.update(account);
}
@Timed
@PUT
@Path("/name/")
public void setName(@Auth Account account, @Valid DeviceName deviceName) {
account.getAuthenticatedDevice().get().setName(deviceName.getDeviceName());
accounts.update(account);
}
@Timed
@DELETE
@Path("/signaling_key")
public void removeSignalingKey(@Auth Account account) {
account.getAuthenticatedDevice().get().setSignalingKey(null);
accounts.update(account);
}
@Timed
@PUT
@Path("/attributes/")
@Consumes(MediaType.APPLICATION_JSON)
public void setAccountAttributes(@Auth Account account,
@HeaderParam("X-Signal-Agent") String userAgent,
@Valid AccountAttributes attributes)
{
Device device = account.getAuthenticatedDevice().get();
device.setFetchesMessages(attributes.getFetchesMessages());
device.setName(attributes.getName());
device.setLastSeen(Util.todayInMillis());
device.setUnauthenticatedDeliverySupported(attributes.getUnidentifiedAccessKey() != null);
device.setRegistrationId(attributes.getRegistrationId());
device.setSignalingKey(attributes.getSignalingKey());
device.setUserAgent(userAgent);
account.setPin(attributes.getPin());
account.setUnidentifiedAccessKey(attributes.getUnidentifiedAccessKey());
account.setUnrestrictedUnidentifiedAccess(attributes.isUnrestrictedUnidentifiedAccess());
accounts.update(account);
}
private CaptchaRequirement requiresCaptcha(String number, String transport, String forwardedFor,
String requester, Optional<String> captchaToken)
{
if (captchaToken.isPresent()) {
boolean validToken = recaptchaClient.verify(captchaToken.get());
if (validToken) {
captchaSuccessMeter.mark();
return new CaptchaRequirement(false, false);
} else {
captchaFailureMeter.mark();
return new CaptchaRequirement(true, false);
}
}
List<AbusiveHostRule> abuseRules = abusiveHostRules.getAbusiveHostRulesFor(requester);
for (AbusiveHostRule abuseRule : abuseRules) {
if (abuseRule.isBlocked()) {
logger.info("Blocked host: " + transport + ", " + number + ", " + requester + " (" + forwardedFor + ")");
blockedHostMeter.mark();
return new CaptchaRequirement(true, false);
}
if (!abuseRule.getRegions().isEmpty()) {
if (abuseRule.getRegions().stream().noneMatch(number::startsWith)) {
logger.info("Restricted host: " + transport + ", " + number + ", " + requester + " (" + forwardedFor + ")");
filteredHostMeter.mark();
return new CaptchaRequirement(true, false);
}
}
}
try {
rateLimiters.getSmsVoiceIpLimiter().validate(requester);
} catch (RateLimitExceededException e) {
logger.info("Rate limited exceeded: " + transport + ", " + number + ", " + requester + " (" + forwardedFor + ")");
rateLimitedHostMeter.mark();
return new CaptchaRequirement(true, true);
}
try {
rateLimiters.getSmsVoicePrefixLimiter().validate(Util.getNumberPrefix(number));
} catch (RateLimitExceededException e) {
logger.info("Prefix rate limit exceeded: " + transport + ", " + number + ", (" + forwardedFor + ")");
rateLimitedPrefixMeter.mark();
return new CaptchaRequirement(true, true);
}
return new CaptchaRequirement(false, false);
}
private boolean shouldAutoBlock(String requester) {
try {
rateLimiters.getAutoBlockLimiter().validate(requester);
} catch (RateLimitExceededException e) {
return true;
}
return false;
}
private void createAccount(String number, String password, String userAgent, AccountAttributes accountAttributes) {
Device device = new Device();
device.setId(Device.MASTER_ID);
device.setAuthenticationCredentials(new AuthenticationCredentials(password));
device.setSignalingKey(accountAttributes.getSignalingKey());
device.setFetchesMessages(accountAttributes.getFetchesMessages());
device.setRegistrationId(accountAttributes.getRegistrationId());
device.setName(accountAttributes.getName());
device.setUnauthenticatedDeliverySupported(accountAttributes.getUnidentifiedAccessKey() != null);
device.setCreated(System.currentTimeMillis());
device.setLastSeen(Util.todayInMillis());
device.setUserAgent(userAgent);
Account account = new Account();
account.setNumber(number);
account.addDevice(device);
account.setPin(accountAttributes.getPin());
account.setUnidentifiedAccessKey(accountAttributes.getUnidentifiedAccessKey());
account.setUnrestrictedUnidentifiedAccess(accountAttributes.isUnrestrictedUnidentifiedAccess());
if (accounts.create(account)) {
newUserMeter.mark();
}
if (account.isActive()) {
directoryQueue.addRegisteredUser(number);
} else {
directoryQueue.deleteRegisteredUser(number);
}
messagesManager.clear(number);
pendingAccounts.remove(number);
}
@VisibleForTesting protected VerificationCode generateVerificationCode(String number) {
if (testDevices.containsKey(number)) {
return new VerificationCode(testDevices.get(number));
}
SecureRandom random = new SecureRandom();
int randomInt = 100000 + random.nextInt(900000);
return new VerificationCode(randomInt);
}
private static class CaptchaRequirement {
private final boolean captchaRequired;
private final boolean autoBlock;
private CaptchaRequirement(boolean captchaRequired, boolean autoBlock) {
this.captchaRequired = captchaRequired;
this.autoBlock = autoBlock;
}
boolean isCaptchaRequired() {
return captchaRequired;
}
boolean isAutoBlock() {
return autoBlock;
}
}
}

View File

@@ -0,0 +1,17 @@
package org.whispersystems.textsecuregcm.controllers;
import org.whispersystems.textsecuregcm.util.Conversions;
import java.security.SecureRandom;
public class AttachmentControllerBase {
protected long generateAttachmentId() {
byte[] attachmentBytes = new byte[8];
new SecureRandom().nextBytes(attachmentBytes);
attachmentBytes[0] = (byte)(attachmentBytes[0] & 0x7F);
return Conversions.byteArrayToLong(attachmentBytes);
}
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2013 Open WhisperSystems
*
* 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 org.whispersystems.textsecuregcm.controllers;
import com.amazonaws.HttpMethod;
import com.codahale.metrics.annotation.Timed;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV1;
import org.whispersystems.textsecuregcm.entities.AttachmentUri;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.UrlSigner;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.util.Conversions;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.net.URL;
import java.security.SecureRandom;
import java.util.stream.Stream;
import io.dropwizard.auth.Auth;
@Path("/v1/attachments")
public class AttachmentControllerV1 extends AttachmentControllerBase {
@SuppressWarnings("unused")
private final Logger logger = LoggerFactory.getLogger(AttachmentControllerV1.class);
private static final String[] UNACCELERATED_REGIONS = {"+20", "+971", "+968", "+974"};
private final RateLimiters rateLimiters;
private final UrlSigner urlSigner;
public AttachmentControllerV1(RateLimiters rateLimiters, String accessKey, String accessSecret, String bucket) {
this.rateLimiters = rateLimiters;
this.urlSigner = new UrlSigner(accessKey, accessSecret, bucket);
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
public AttachmentDescriptorV1 allocateAttachment(@Auth Account account)
throws RateLimitExceededException
{
if (account.isRateLimited()) {
rateLimiters.getAttachmentLimiter().validate(account.getNumber());
}
long attachmentId = generateAttachmentId();
URL url = urlSigner.getPreSignedUrl(attachmentId, HttpMethod.PUT, Stream.of(UNACCELERATED_REGIONS).anyMatch(region -> account.getNumber().startsWith(region)));
return new AttachmentDescriptorV1(attachmentId, url.toExternalForm());
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/{attachmentId}")
public AttachmentUri redirectToAttachment(@Auth Account account,
@PathParam("attachmentId") long attachmentId)
throws IOException
{
return new AttachmentUri(urlSigner.getPreSignedUrl(attachmentId, HttpMethod.GET, Stream.of(UNACCELERATED_REGIONS).anyMatch(region -> account.getNumber().startsWith(region))));
}
}

View File

@@ -0,0 +1,54 @@
package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV2;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.util.Pair;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import io.dropwizard.auth.Auth;
@Path("/v2/attachments")
public class AttachmentControllerV2 extends AttachmentControllerBase {
private final PostPolicyGenerator policyGenerator;
private final PolicySigner policySigner;
private final RateLimiter rateLimiter;
public AttachmentControllerV2(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, String bucket) {
this.rateLimiter = rateLimiters.getAttachmentLimiter();
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
this.policySigner = new PolicySigner(accessSecret, region);
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/form/upload")
public AttachmentDescriptorV2 getAttachmentUploadForm(@Auth Account account) throws RateLimitExceededException {
rateLimiter.validate(account.getNumber());
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
long attachmentId = generateAttachmentId();
String objectName = String.valueOf(attachmentId);
Pair<String, String> policy = policyGenerator.createFor(now, String.valueOf(objectName));
String signature = policySigner.getSignature(now, policy.second());
return new AttachmentDescriptorV2(attachmentId, objectName, policy.first(),
"private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME),
policy.second(), signature);
}
}

View File

@@ -0,0 +1,48 @@
package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.entities.DeliveryCertificate;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.util.Util;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.security.InvalidKeyException;
import io.dropwizard.auth.Auth;
@Path("/v1/certificate")
public class CertificateController {
private final Logger logger = LoggerFactory.getLogger(CertificateController.class);
private final CertificateGenerator certificateGenerator;
public CertificateController(CertificateGenerator certificateGenerator) {
this.certificateGenerator = certificateGenerator;
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/delivery")
public DeliveryCertificate getDeliveryCertificate(@Auth Account account) throws IOException, InvalidKeyException {
if (!account.getAuthenticatedDevice().isPresent()) throw new AssertionError();
if (Util.isEmpty(account.getIdentityKey())) {
throw new WebApplicationException(Response.Status.BAD_REQUEST);
}
return new DeliveryCertificate(certificateGenerator.createFor(account, account.getAuthenticatedDevice().get()));
}
}

View File

@@ -0,0 +1,230 @@
/*
* Copyright (C) 2013-2018 Open WhisperSystems
*
* 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 org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.auth.AuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException;
import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.DeviceInfo;
import org.whispersystems.textsecuregcm.entities.DeviceInfoList;
import org.whispersystems.textsecuregcm.entities.DeviceResponse;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PendingDevicesManager;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.util.VerificationCode;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.security.SecureRandom;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import io.dropwizard.auth.Auth;
@Path("/v1/devices")
public class DeviceController {
private final Logger logger = LoggerFactory.getLogger(DeviceController.class);
private static final int MAX_DEVICES = 6;
private final PendingDevicesManager pendingDevices;
private final AccountsManager accounts;
private final MessagesManager messages;
private final RateLimiters rateLimiters;
private final Map<String, Integer> maxDeviceConfiguration;
private final DirectoryQueue directoryQueue;
public DeviceController(PendingDevicesManager pendingDevices,
AccountsManager accounts,
MessagesManager messages,
DirectoryQueue directoryQueue,
RateLimiters rateLimiters,
Map<String, Integer> maxDeviceConfiguration)
{
this.pendingDevices = pendingDevices;
this.accounts = accounts;
this.messages = messages;
this.directoryQueue = directoryQueue;
this.rateLimiters = rateLimiters;
this.maxDeviceConfiguration = maxDeviceConfiguration;
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
public DeviceInfoList getDevices(@Auth Account account) {
List<DeviceInfo> devices = new LinkedList<>();
for (Device device : account.getDevices()) {
devices.add(new DeviceInfo(device.getId(), device.getName(),
device.getLastSeen(), device.getCreated()));
}
return new DeviceInfoList(devices);
}
@Timed
@DELETE
@Path("/{device_id}")
public void removeDevice(@Auth Account account, @PathParam("device_id") long deviceId) {
if (account.getAuthenticatedDevice().get().getId() != Device.MASTER_ID) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
account.removeDevice(deviceId);
accounts.update(account);
if (!account.isActive()) {
directoryQueue.deleteRegisteredUser(account.getNumber());
}
messages.clear(account.getNumber(), deviceId);
}
@Timed
@GET
@Path("/provisioning/code")
@Produces(MediaType.APPLICATION_JSON)
public VerificationCode createDeviceToken(@Auth Account account)
throws RateLimitExceededException, DeviceLimitExceededException
{
rateLimiters.getAllocateDeviceLimiter().validate(account.getNumber());
int maxDeviceLimit = MAX_DEVICES;
if (maxDeviceConfiguration.containsKey(account.getNumber())) {
maxDeviceLimit = maxDeviceConfiguration.get(account.getNumber());
}
if (account.getActiveDeviceCount() >= maxDeviceLimit) {
throw new DeviceLimitExceededException(account.getDevices().size(), MAX_DEVICES);
}
if (account.getAuthenticatedDevice().get().getId() != Device.MASTER_ID) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
VerificationCode verificationCode = generateVerificationCode();
StoredVerificationCode storedVerificationCode = new StoredVerificationCode(verificationCode.getVerificationCode(),
System.currentTimeMillis());
pendingDevices.store(account.getNumber(), storedVerificationCode);
return verificationCode;
}
@Timed
@PUT
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Path("/{verification_code}")
public DeviceResponse verifyDeviceToken(@PathParam("verification_code") String verificationCode,
@HeaderParam("Authorization") String authorizationHeader,
@Valid AccountAttributes accountAttributes)
throws RateLimitExceededException, DeviceLimitExceededException
{
try {
AuthorizationHeader header = AuthorizationHeader.fromFullHeader(authorizationHeader);
String number = header.getNumber();
String password = header.getPassword();
rateLimiters.getVerifyDeviceLimiter().validate(number);
Optional<StoredVerificationCode> storedVerificationCode = pendingDevices.getCodeForNumber(number);
if (!storedVerificationCode.isPresent() || !storedVerificationCode.get().isValid(verificationCode)) {
throw new WebApplicationException(Response.status(403).build());
}
Optional<Account> account = accounts.get(number);
if (!account.isPresent()) {
throw new WebApplicationException(Response.status(403).build());
}
int maxDeviceLimit = MAX_DEVICES;
if (maxDeviceConfiguration.containsKey(account.get().getNumber())) {
maxDeviceLimit = maxDeviceConfiguration.get(account.get().getNumber());
}
if (account.get().getActiveDeviceCount() >= maxDeviceLimit) {
throw new DeviceLimitExceededException(account.get().getDevices().size(), MAX_DEVICES);
}
Device device = new Device();
device.setName(accountAttributes.getName());
device.setAuthenticationCredentials(new AuthenticationCredentials(password));
device.setSignalingKey(accountAttributes.getSignalingKey());
device.setFetchesMessages(accountAttributes.getFetchesMessages());
device.setId(account.get().getNextDeviceId());
device.setRegistrationId(accountAttributes.getRegistrationId());
device.setLastSeen(Util.todayInMillis());
device.setCreated(System.currentTimeMillis());
account.get().addDevice(device);
messages.clear(account.get().getNumber(), device.getId());
accounts.update(account.get());
pendingDevices.remove(number);
return new DeviceResponse(device.getId());
} catch (InvalidAuthorizationHeaderException e) {
logger.info("Bad Authorization Header", e);
throw new WebApplicationException(Response.status(401).build());
}
}
@Timed
@PUT
@Path("/unauthenticated_delivery")
public void setUnauthenticatedDelivery(@Auth Account account) {
assert(account.getAuthenticatedDevice().isPresent());
account.getAuthenticatedDevice().get().setUnauthenticatedDeliverySupported(true);
accounts.update(account);
}
@VisibleForTesting protected VerificationCode generateVerificationCode() {
SecureRandom random = new SecureRandom();
int randomInt = 100000 + random.nextInt(900000);
return new VerificationCode(randomInt);
}
}

View File

@@ -0,0 +1,21 @@
package org.whispersystems.textsecuregcm.controllers;
public class DeviceLimitExceededException extends Exception {
private final int currentDevices;
private final int maxDevices;
public DeviceLimitExceededException(int currentDevices, int maxDevices) {
this.currentDevices = currentDevices;
this.maxDevices = maxDevices;
}
public int getCurrentDevices() {
return currentDevices;
}
public int getMaxDevices() {
return maxDevices;
}
}

View File

@@ -0,0 +1,199 @@
/*
* Copyright (C) 2013 Open WhisperSystems
*
* 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 org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.Histogram;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.annotation.Timed;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.DirectoryCredentialsGenerator;
import org.whispersystems.textsecuregcm.entities.ClientContact;
import org.whispersystems.textsecuregcm.entities.ClientContactTokens;
import org.whispersystems.textsecuregcm.entities.ClientContacts;
import org.whispersystems.textsecuregcm.entities.DirectoryFeedbackRequest;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DirectoryManager;
import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.util.Constants;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import java.io.IOException;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.auth.Auth;
@Path("/v1/directory")
public class DirectoryController {
private static final String[] FEEDBACK_STATUSES = {
"ok",
"mismatch",
"attestation-error",
"unexpected-error",
};
private final Logger logger = LoggerFactory.getLogger(DirectoryController.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Histogram contactsHistogram = metricRegistry.histogram(name(getClass(), "contacts"));
private final Map<String, Meter> iosFeedbackMeters = new HashMap<String, Meter>() {{
for (String status : FEEDBACK_STATUSES) {
put(status, metricRegistry.meter(name(DirectoryController.class, "feedback-v2", "ios", status)));
}
}};
private final Map<String, Meter> androidFeedbackMeters = new HashMap<String, Meter>() {{
for (String status : FEEDBACK_STATUSES) {
put(status, metricRegistry.meter(name(DirectoryController.class, "feedback-v2", "android", status)));
}
}};
private final Map<String, Meter> unknownFeedbackMeters = new HashMap<String, Meter>() {{
for (String status : FEEDBACK_STATUSES) {
put(status, metricRegistry.meter(name(DirectoryController.class, "feedback-v2", "unknown", status)));
}
}};
private final RateLimiters rateLimiters;
private final DirectoryManager directory;
private final DirectoryCredentialsGenerator userTokenGenerator;
public DirectoryController(RateLimiters rateLimiters,
DirectoryManager directory,
DirectoryCredentialsGenerator userTokenGenerator)
{
this.directory = directory;
this.rateLimiters = rateLimiters;
this.userTokenGenerator = userTokenGenerator;
}
@Timed
@GET
@Path("/auth")
@Produces(MediaType.APPLICATION_JSON)
public Response getAuthToken(@Auth Account account) {
return Response.ok().entity(userTokenGenerator.generateFor(account.getNumber())).build();
}
@PUT
@Path("/feedback-v2/{status}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response setFeedback(@Auth Account account,
@PathParam("status") String status,
@Valid DirectoryFeedbackRequest request)
{
Map<String, Meter> platformFeedbackMeters = unknownFeedbackMeters;
String platformName = "unknown";
Optional<Device> masterDevice = account.getMasterDevice();
if (masterDevice.isPresent()) {
if (masterDevice.get().getApnId() != null) {
platformFeedbackMeters = iosFeedbackMeters;
} else if (masterDevice.get().getGcmId() != null) {
platformFeedbackMeters = androidFeedbackMeters;
}
}
Optional<Meter> meter = Optional.ofNullable(platformFeedbackMeters.get(status));
if (meter.isPresent()) {
meter.get().mark();
// if (!"ok".equals(status) &&
// request != null &&
// request.getReason().isPresent() &&
// request.getReason().get().length() != 0)
// {
// logger.info("directory feedback platform=" + platformName + " status=" + status + ": " + request.getReason().get());
// }
return Response.ok().build();
} else {
return Response.status(Status.NOT_FOUND).build();
}
}
@Timed
@GET
@Path("/{token}")
@Produces(MediaType.APPLICATION_JSON)
public Response getTokenPresence(@Auth Account account, @PathParam("token") String token)
throws RateLimitExceededException
{
rateLimiters.getContactsLimiter().validate(account.getNumber());
try {
Optional<ClientContact> contact = directory.get(decodeToken(token));
if (contact.isPresent()) return Response.ok().entity(contact.get()).build();
else return Response.status(404).build();
} catch (IOException e) {
logger.info("Bad token", e);
return Response.status(404).build();
}
}
@Timed
@PUT
@Path("/tokens")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public ClientContacts getContactIntersection(@Auth Account account, @Valid ClientContactTokens contacts)
throws RateLimitExceededException
{
rateLimiters.getContactsLimiter().validate(account.getNumber(), contacts.getContacts().size());
contactsHistogram.update(contacts.getContacts().size());
try {
List<byte[]> tokens = new LinkedList<>();
for (String encodedContact : contacts.getContacts()) {
tokens.add(decodeToken(encodedContact));
}
List<ClientContact> intersection = directory.get(tokens);
return new ClientContacts(intersection);
} catch (IOException e) {
logger.info("Bad token", e);
throw new WebApplicationException(Response.status(400).build());
}
}
private byte[] decodeToken(String encoded) throws IOException {
return Base64.decodeWithoutPadding(encoded.replace('-', '+').replace('_', '/'));
}
}

View File

@@ -0,0 +1,7 @@
package org.whispersystems.textsecuregcm.controllers;
public class InvalidDestinationException extends Exception {
public InvalidDestinationException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,55 @@
package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.PubSubManager;
import org.whispersystems.textsecuregcm.websocket.WebsocketAddress;
import org.whispersystems.websocket.session.WebSocketSession;
import org.whispersystems.websocket.session.WebSocketSessionContext;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import io.dropwizard.auth.Auth;
@Path("/v1/keepalive")
public class KeepAliveController {
private final Logger logger = LoggerFactory.getLogger(KeepAliveController.class);
private final PubSubManager pubSubManager;
public KeepAliveController(PubSubManager pubSubManager) {
this.pubSubManager = pubSubManager;
}
@Timed
@GET
public Response getKeepAlive(@Auth Account account,
@WebSocketSession WebSocketSessionContext context)
{
if (account != null) {
WebsocketAddress address = new WebsocketAddress(account.getNumber(),
account.getAuthenticatedDevice().get().getId());
if (!pubSubManager.hasLocalSubscription(address)) {
logger.warn("***** No local subscription found for: " + address);
context.getClient().close(1000, "OK");
}
}
return Response.ok().build();
}
@Timed
@GET
@Path("/provisioning")
public Response getProvisioningKeepAlive() {
return Response.ok().build();
}
}

View File

@@ -0,0 +1,202 @@
/*
* Copyright (C) 2014 Open Whisper Systems
*
* 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 org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.Anonymous;
import org.whispersystems.textsecuregcm.auth.OptionalAccess;
import org.whispersystems.textsecuregcm.entities.PreKey;
import org.whispersystems.textsecuregcm.entities.PreKeyCount;
import org.whispersystems.textsecuregcm.entities.PreKeyResponse;
import org.whispersystems.textsecuregcm.entities.PreKeyResponseItem;
import org.whispersystems.textsecuregcm.entities.PreKeyState;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.KeyRecord;
import org.whispersystems.textsecuregcm.storage.Keys;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import io.dropwizard.auth.Auth;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v2/keys")
public class KeysController {
private static final Logger logger = LoggerFactory.getLogger(KeysController.class);
private final RateLimiters rateLimiters;
private final Keys keys;
private final AccountsManager accounts;
private final DirectoryQueue directoryQueue;
public KeysController(RateLimiters rateLimiters, Keys keys, AccountsManager accounts, DirectoryQueue directoryQueue) {
this.rateLimiters = rateLimiters;
this.keys = keys;
this.accounts = accounts;
this.directoryQueue = directoryQueue;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
public PreKeyCount getStatus(@Auth Account account) {
int count = keys.getCount(account.getNumber(), account.getAuthenticatedDevice().get().getId());
if (count > 0) {
count = count - 1;
}
return new PreKeyCount(count);
}
@Timed
@PUT
@Consumes(MediaType.APPLICATION_JSON)
public void setKeys(@Auth Account account, @Valid PreKeyState preKeys) {
Device device = account.getAuthenticatedDevice().get();
boolean wasAccountActive = account.isActive();
boolean updateAccount = false;
if (!preKeys.getSignedPreKey().equals(device.getSignedPreKey())) {
device.setSignedPreKey(preKeys.getSignedPreKey());
updateAccount = true;
}
if (!preKeys.getIdentityKey().equals(account.getIdentityKey())) {
account.setIdentityKey(preKeys.getIdentityKey());
updateAccount = true;
}
if (updateAccount) {
accounts.update(account);
if (!wasAccountActive && account.isActive()) {
directoryQueue.addRegisteredUser(account.getNumber());
}
}
keys.store(account.getNumber(), device.getId(), preKeys.getPreKeys());
}
@Timed
@GET
@Path("/{number}/{device_id}")
@Produces(MediaType.APPLICATION_JSON)
public Optional<PreKeyResponse> getDeviceKeys(@Auth Optional<Account> account,
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
@PathParam("number") String number,
@PathParam("device_id") String deviceId)
throws RateLimitExceededException
{
if (!account.isPresent() && !accessKey.isPresent()) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
Optional<Account> target = accounts.get(number);
OptionalAccess.verify(account, accessKey, target, deviceId);
assert(target.isPresent());
if (account.isPresent()) {
rateLimiters.getPreKeysLimiter().validate(account.get().getNumber() + "__" + number + "." + deviceId);
}
List<KeyRecord> targetKeys = getLocalKeys(target.get(), deviceId);
List<PreKeyResponseItem> devices = new LinkedList<>();
for (Device device : target.get().getDevices()) {
if (device.isActive() && (deviceId.equals("*") || device.getId() == Long.parseLong(deviceId))) {
SignedPreKey signedPreKey = device.getSignedPreKey();
PreKey preKey = null;
for (KeyRecord keyRecord : targetKeys) {
if (keyRecord.getDeviceId() == device.getId()) {
preKey = new PreKey(keyRecord.getKeyId(), keyRecord.getPublicKey());
}
}
if (signedPreKey != null || preKey != null) {
devices.add(new PreKeyResponseItem(device.getId(), device.getRegistrationId(), signedPreKey, preKey));
}
}
}
if (devices.isEmpty()) return Optional.empty();
else return Optional.of(new PreKeyResponse(target.get().getIdentityKey(), devices));
}
@Timed
@PUT
@Path("/signed")
@Consumes(MediaType.APPLICATION_JSON)
public void setSignedKey(@Auth Account account, @Valid SignedPreKey signedPreKey) {
Device device = account.getAuthenticatedDevice().get();
boolean wasAccountActive = account.isActive();
device.setSignedPreKey(signedPreKey);
accounts.update(account);
if (!wasAccountActive && account.isActive()) {
directoryQueue.addRegisteredUser(account.getNumber());
}
}
@Timed
@GET
@Path("/signed")
@Produces(MediaType.APPLICATION_JSON)
public Optional<SignedPreKey> getSignedKey(@Auth Account account) {
Device device = account.getAuthenticatedDevice().get();
SignedPreKey signedPreKey = device.getSignedPreKey();
if (signedPreKey != null) return Optional.of(signedPreKey);
else return Optional.empty();
}
private List<KeyRecord> getLocalKeys(Account destination, String deviceIdSelector) {
try {
if (deviceIdSelector.equals("*")) {
return keys.get(destination.getNumber());
}
long deviceId = Long.parseLong(deviceIdSelector);
return keys.get(destination.getNumber(), deviceId);
} catch (NumberFormatException e) {
throw new WebApplicationException(Response.status(422).build());
}
}
}

View File

@@ -0,0 +1,347 @@
/*
* Copyright (C) 2013 Open WhisperSystems
*
* 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 org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.annotation.Timed;
import com.google.protobuf.ByteString;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.Anonymous;
import org.whispersystems.textsecuregcm.auth.OptionalAccess;
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.push.ApnFallbackManager;
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
import org.whispersystems.textsecuregcm.push.PushSender;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.redis.RedisOperation;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.auth.Auth;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/messages")
public class MessageController {
private final Logger logger = LoggerFactory.getLogger(MessageController.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter unidentifiedMeter = metricRegistry.meter(name(getClass(), "delivery", "unidentified"));
private final Meter identifiedMeter = metricRegistry.meter(name(getClass(), "delivery", "identified" ));
private final RateLimiters rateLimiters;
private final PushSender pushSender;
private final ReceiptSender receiptSender;
private final AccountsManager accountsManager;
private final MessagesManager messagesManager;
private final ApnFallbackManager apnFallbackManager;
public MessageController(RateLimiters rateLimiters,
PushSender pushSender,
ReceiptSender receiptSender,
AccountsManager accountsManager,
MessagesManager messagesManager,
ApnFallbackManager apnFallbackManager)
{
this.rateLimiters = rateLimiters;
this.pushSender = pushSender;
this.receiptSender = receiptSender;
this.accountsManager = accountsManager;
this.messagesManager = messagesManager;
this.apnFallbackManager = apnFallbackManager;
}
@Timed
@Path("/{destination}")
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public SendMessageResponse sendMessage(@Auth Optional<Account> source,
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
@PathParam("destination") String destinationName,
@Valid IncomingMessageList messages)
throws RateLimitExceededException
{
if (!source.isPresent() && !accessKey.isPresent()) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
if (source.isPresent() && !source.get().getNumber().equals(destinationName)) {
rateLimiters.getMessagesLimiter().validate(source.get().getNumber() + "__" + destinationName);
}
if (source.isPresent() && !source.get().getNumber().equals(destinationName)) {
identifiedMeter.mark();
} else {
unidentifiedMeter.mark();
}
try {
boolean isSyncMessage = source.isPresent() && source.get().getNumber().equals(destinationName);
Optional<Account> destination;
if (!isSyncMessage) destination = accountsManager.get(destinationName);
else destination = source;
OptionalAccess.verify(source, accessKey, destination);
assert(destination.isPresent());
validateCompleteDeviceList(destination.get(), messages.getMessages(), isSyncMessage);
validateRegistrationIds(destination.get(), messages.getMessages());
for (IncomingMessage incomingMessage : messages.getMessages()) {
Optional<Device> destinationDevice = destination.get().getDevice(incomingMessage.getDestinationDeviceId());
if (destinationDevice.isPresent()) {
sendMessage(source, destination.get(), destinationDevice.get(), messages.getTimestamp(), messages.isOnline(), incomingMessage);
}
}
return new SendMessageResponse(!isSyncMessage && source.isPresent() && source.get().getActiveDeviceCount() > 1);
} catch (NoSuchUserException e) {
throw new WebApplicationException(Response.status(404).build());
} catch (MismatchedDevicesException e) {
throw new WebApplicationException(Response.status(409)
.type(MediaType.APPLICATION_JSON_TYPE)
.entity(new MismatchedDevices(e.getMissingDevices(),
e.getExtraDevices()))
.build());
} catch (StaleDevicesException e) {
throw new WebApplicationException(Response.status(410)
.type(MediaType.APPLICATION_JSON)
.entity(new StaleDevices(e.getStaleDevices()))
.build());
}
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
public OutgoingMessageEntityList getPendingMessages(@Auth Account account) {
assert account.getAuthenticatedDevice().isPresent();
if (!Util.isEmpty(account.getAuthenticatedDevice().get().getApnId())) {
RedisOperation.unchecked(() -> apnFallbackManager.cancel(account, account.getAuthenticatedDevice().get()));
}
return messagesManager.getMessagesForDevice(account.getNumber(),
account.getAuthenticatedDevice().get().getId());
}
@Timed
@DELETE
@Path("/{source}/{timestamp}")
public void removePendingMessage(@Auth Account account,
@PathParam("source") String source,
@PathParam("timestamp") long timestamp)
{
try {
WebSocketConnection.messageTime.update(System.currentTimeMillis() - timestamp);
Optional<OutgoingMessageEntity> message = messagesManager.delete(account.getNumber(),
account.getAuthenticatedDevice().get().getId(),
source, timestamp);
if (message.isPresent() && message.get().getType() != Envelope.Type.RECEIPT_VALUE) {
receiptSender.sendReceipt(account,
message.get().getSource(),
message.get().getTimestamp());
}
} catch (NotPushRegisteredException e) {
logger.info("User no longer push registered for delivery receipt: " + e.getMessage());
} catch (NoSuchUserException e) {
logger.warn("Sending delivery receipt", e);
}
}
@Timed
@DELETE
@Path("/uuid/{uuid}")
public void removePendingMessage(@Auth Account account, @PathParam("uuid") UUID uuid) {
try {
Optional<OutgoingMessageEntity> message = messagesManager.delete(account.getNumber(),
account.getAuthenticatedDevice().get().getId(),
uuid);
message.ifPresent(outgoingMessageEntity -> WebSocketConnection.messageTime.update(System.currentTimeMillis() - outgoingMessageEntity.getTimestamp()));
if (message.isPresent() && !Util.isEmpty(message.get().getSource()) && message.get().getType() != Envelope.Type.RECEIPT_VALUE) {
receiptSender.sendReceipt(account, message.get().getSource(), message.get().getTimestamp());
}
} catch (NoSuchUserException e) {
logger.warn("Sending delivery receipt", e);
} catch (NotPushRegisteredException e) {
logger.info("User no longer push registered for delivery receipt: " + e.getMessage());
}
}
private void sendMessage(Optional<Account> source,
Account destinationAccount,
Device destinationDevice,
long timestamp,
boolean online,
IncomingMessage incomingMessage)
throws NoSuchUserException
{
try {
Optional<byte[]> messageBody = getMessageBody(incomingMessage);
Optional<byte[]> messageContent = getMessageContent(incomingMessage);
Envelope.Builder messageBuilder = Envelope.newBuilder();
messageBuilder.setType(Envelope.Type.valueOf(incomingMessage.getType()))
.setTimestamp(timestamp == 0 ? System.currentTimeMillis() : timestamp)
.setServerTimestamp(System.currentTimeMillis());
if (source.isPresent()) {
messageBuilder.setSource(source.get().getNumber())
.setSourceDevice((int)source.get().getAuthenticatedDevice().get().getId());
}
if (messageBody.isPresent()) {
messageBuilder.setLegacyMessage(ByteString.copyFrom(messageBody.get()));
}
if (messageContent.isPresent()) {
messageBuilder.setContent(ByteString.copyFrom(messageContent.get()));
}
pushSender.sendMessage(destinationAccount, destinationDevice, messageBuilder.build(), online);
} catch (NotPushRegisteredException e) {
if (destinationDevice.isMaster()) throw new NoSuchUserException(e);
else logger.debug("Not registered", e);
}
}
private void validateRegistrationIds(Account account, List<IncomingMessage> messages)
throws StaleDevicesException
{
List<Long> staleDevices = new LinkedList<>();
for (IncomingMessage message : messages) {
Optional<Device> device = account.getDevice(message.getDestinationDeviceId());
if (device.isPresent() &&
message.getDestinationRegistrationId() > 0 &&
message.getDestinationRegistrationId() != device.get().getRegistrationId())
{
staleDevices.add(device.get().getId());
}
}
if (!staleDevices.isEmpty()) {
throw new StaleDevicesException(staleDevices);
}
}
private void validateCompleteDeviceList(Account account,
List<IncomingMessage> messages,
boolean isSyncMessage)
throws MismatchedDevicesException
{
Set<Long> messageDeviceIds = new HashSet<>();
Set<Long> accountDeviceIds = new HashSet<>();
List<Long> missingDeviceIds = new LinkedList<>();
List<Long> extraDeviceIds = new LinkedList<>();
for (IncomingMessage message : messages) {
messageDeviceIds.add(message.getDestinationDeviceId());
}
for (Device device : account.getDevices()) {
if (device.isActive() &&
!(isSyncMessage && device.getId() == account.getAuthenticatedDevice().get().getId()))
{
accountDeviceIds.add(device.getId());
if (!messageDeviceIds.contains(device.getId())) {
missingDeviceIds.add(device.getId());
}
}
}
for (IncomingMessage message : messages) {
if (!accountDeviceIds.contains(message.getDestinationDeviceId())) {
extraDeviceIds.add(message.getDestinationDeviceId());
}
}
if (!missingDeviceIds.isEmpty() || !extraDeviceIds.isEmpty()) {
throw new MismatchedDevicesException(missingDeviceIds, extraDeviceIds);
}
}
private Optional<byte[]> getMessageBody(IncomingMessage message) {
if (Util.isEmpty(message.getBody())) return Optional.empty();
try {
return Optional.of(Base64.decode(message.getBody()));
} catch (IOException ioe) {
logger.debug("Bad B64", ioe);
return Optional.empty();
}
}
private Optional<byte[]> getMessageContent(IncomingMessage message) {
if (Util.isEmpty(message.getContent())) return Optional.empty();
try {
return Optional.of(Base64.decode(message.getContent()));
} catch (IOException ioe) {
logger.debug("Bad B64", ioe);
return Optional.empty();
}
}
}

View File

@@ -0,0 +1,22 @@
package org.whispersystems.textsecuregcm.controllers;
import java.util.List;
public class MismatchedDevicesException extends Exception {
private final List<Long> missingDevices;
private final List<Long> extraDevices;
public MismatchedDevicesException(List<Long> missingDevices, List<Long> extraDevices) {
this.missingDevices = missingDevices;
this.extraDevices = extraDevices;
}
public List<Long> getMissingDevices() {
return missingDevices;
}
public List<Long> getExtraDevices() {
return extraDevices;
}
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* 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 org.whispersystems.textsecuregcm.controllers;
import java.util.LinkedList;
import java.util.List;
public class NoSuchUserException extends Exception {
private List<String> missing;
public NoSuchUserException(String user) {
super(user);
missing = new LinkedList<>();
missing.add(user);
}
public NoSuchUserException(List<String> missing) {
this.missing = missing;
}
public NoSuchUserException(Exception e) {
super(e);
}
public List<String> getMissing() {
return missing;
}
}

View File

@@ -0,0 +1,146 @@
package org.whispersystems.textsecuregcm.controllers;
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSCredentialsProvider;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3Client;
import com.codahale.metrics.annotation.Timed;
import org.apache.commons.codec.binary.Base64;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.valuehandling.UnwrapValidatedValue;
import org.whispersystems.textsecuregcm.auth.OptionalAccess;
import org.whispersystems.textsecuregcm.auth.Anonymous;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.configuration.ProfilesConfiguration;
import org.whispersystems.textsecuregcm.entities.Profile;
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.Pair;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.security.SecureRandom;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Optional;
import io.dropwizard.auth.Auth;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/profile")
public class ProfileController {
private final RateLimiters rateLimiters;
private final AccountsManager accountsManager;
private final PolicySigner policySigner;
private final PostPolicyGenerator policyGenerator;
private final AmazonS3 s3client;
private final String bucket;
public ProfileController(RateLimiters rateLimiters,
AccountsManager accountsManager,
ProfilesConfiguration profilesConfiguration)
{
AWSCredentials credentials = new BasicAWSCredentials(profilesConfiguration.getAccessKey(), profilesConfiguration.getAccessSecret());
AWSCredentialsProvider credentialsProvider = new AWSStaticCredentialsProvider(credentials);
this.rateLimiters = rateLimiters;
this.accountsManager = accountsManager;
this.bucket = profilesConfiguration.getBucket();
this.s3client = AmazonS3Client.builder()
.withCredentials(credentialsProvider)
.withRegion(profilesConfiguration.getRegion())
.build();
this.policyGenerator = new PostPolicyGenerator(profilesConfiguration.getRegion(),
profilesConfiguration.getBucket(),
profilesConfiguration.getAccessKey());
this.policySigner = new PolicySigner(profilesConfiguration.getAccessSecret(),
profilesConfiguration.getRegion());
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/{number}")
public Profile getProfile(@Auth Optional<Account> requestAccount,
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
@PathParam("number") String number,
@QueryParam("ca") boolean useCaCertificate)
throws RateLimitExceededException
{
if (!requestAccount.isPresent() && !accessKey.isPresent()) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
if (requestAccount.isPresent()) {
rateLimiters.getProfileLimiter().validate(requestAccount.get().getNumber());
}
Optional<Account> accountProfile = accountsManager.get(number);
OptionalAccess.verify(requestAccount, accessKey, accountProfile);
//noinspection ConstantConditions,OptionalGetWithoutIsPresent
return new Profile(accountProfile.get().getProfileName(),
accountProfile.get().getAvatar(),
accountProfile.get().getIdentityKey(),
accountProfile.get().isUnauthenticatedDeliverySupported() ? UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()) : null,
accountProfile.get().isUnrestrictedUnidentifiedAccess());
}
@Timed
@PUT
@Produces(MediaType.APPLICATION_JSON)
@Path("/name/{name}")
public void setProfile(@Auth Account account, @PathParam("name") @UnwrapValidatedValue(true) @Length(min = 72,max= 72) Optional<String> name) {
account.setProfileName(name.orElse(null));
accountsManager.update(account);
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/form/avatar")
public ProfileAvatarUploadAttributes getAvatarUploadForm(@Auth Account account) {
String previousAvatar = account.getAvatar();
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
String objectName = generateAvatarObjectName();
Pair<String, String> policy = policyGenerator.createFor(now, objectName);
String signature = policySigner.getSignature(now, policy.second());
if (previousAvatar != null && previousAvatar.startsWith("profiles/")) {
s3client.deleteObject(bucket, previousAvatar);
}
account.setAvatar(objectName);
accountsManager.update(account);
return new ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
}
private String generateAvatarObjectName() {
byte[] object = new byte[16];
new SecureRandom().nextBytes(object);
return "profiles/" + Base64.encodeBase64URLSafeString(object);
}
}

View File

@@ -0,0 +1,55 @@
package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import org.whispersystems.textsecuregcm.entities.ProvisioningMessage;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.push.PushSender;
import org.whispersystems.textsecuregcm.push.WebsocketSender;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.websocket.InvalidWebsocketAddressException;
import org.whispersystems.textsecuregcm.websocket.ProvisioningAddress;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import io.dropwizard.auth.Auth;
@Path("/v1/provisioning")
public class ProvisioningController {
private final RateLimiters rateLimiters;
private final WebsocketSender websocketSender;
public ProvisioningController(RateLimiters rateLimiters, PushSender pushSender) {
this.rateLimiters = rateLimiters;
this.websocketSender = pushSender.getWebSocketSender();
}
@Timed
@Path("/{destination}")
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public void sendProvisioningMessage(@Auth Account source,
@PathParam("destination") String destinationName,
@Valid ProvisioningMessage message)
throws RateLimitExceededException, InvalidWebsocketAddressException, IOException
{
rateLimiters.getMessagesLimiter().validate(source.getNumber());
if (!websocketSender.sendProvisioningMessage(new ProvisioningAddress(destinationName, 0),
Base64.decode(message.getBody())))
{
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* 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 org.whispersystems.textsecuregcm.controllers;
public class RateLimitExceededException extends Exception {
public RateLimitExceededException(String number) {
super(number);
}
}

View File

@@ -0,0 +1,16 @@
package org.whispersystems.textsecuregcm.controllers;
import java.util.List;
public class StaleDevicesException extends Throwable {
private final List<Long> staleDevices;
public StaleDevicesException(List<Long> staleDevices) {
this.staleDevices = staleDevices;
}
public List<Long> getStaleDevices() {
return staleDevices;
}
}

View File

@@ -0,0 +1,42 @@
package org.whispersystems.textsecuregcm.controllers;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.PublicAccount;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.util.Map;
import java.util.Optional;
@Path("/v1/transparency/")
public class TransparentDataController {
private final AccountsManager accountsManager;
private final Map<String, String> transparentDataIndex;
public TransparentDataController(AccountsManager accountsManager,
Map<String, String> transparentDataIndex)
{
this.accountsManager = accountsManager;
this.transparentDataIndex = transparentDataIndex;
}
@GET
@Path("/account/{id}")
@Produces(MediaType.APPLICATION_JSON)
public Optional<PublicAccount> getAccount(@PathParam("id") String id) {
String index = transparentDataIndex.get(id);
if (index != null) {
return accountsManager.get(index).map(PublicAccount::new);
}
return Optional.empty();
}
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* 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 org.whispersystems.textsecuregcm.controllers;
public class ValidationException extends Exception {
public ValidationException(String s) {
super(s);
}
}

View File

@@ -0,0 +1,112 @@
package org.whispersystems.textsecuregcm.controllers;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.Optional;
import java.util.Set;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/voice/")
public class VoiceVerificationController {
private static final String PLAY_TWIML = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<Response>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Pause length=\"1\"/>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Pause length=\"1\"/>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
"</Response>";
private final String baseUrl;
private final Set<String> supportedLocales;
public VoiceVerificationController(String baseUrl, Set<String> supportedLocales) {
this.baseUrl = baseUrl;
this.supportedLocales = supportedLocales;
}
@POST
@Path("/description/{code}")
@Produces(MediaType.APPLICATION_XML)
public Response getDescription(@PathParam("code") String code, @QueryParam("l") String locale) {
code = code.replaceAll("[^0-9]", "");
if (code.length() != 6) {
return Response.status(400).build();
}
if (locale != null && supportedLocales.contains(locale)) {
return getLocalizedDescription(code, locale);
}
if (locale != null && locale.split("-").length >= 1 && supportedLocales.contains(locale.split("-")[0])) {
return getLocalizedDescription(code, locale.split("-")[0]);
}
return getLocalizedDescription(code, "en-US");
}
private Response getLocalizedDescription(String code, String locale) {
String path = constructUrlForLocale(baseUrl, locale);
return Response.ok()
.entity(String.format(PLAY_TWIML,
path + "verification.mp3",
path + code.charAt(0) + "_middle.mp3",
path + code.charAt(1) + "_middle.mp3",
path + code.charAt(2) + "_middle.mp3",
path + code.charAt(3) + "_middle.mp3",
path + code.charAt(4) + "_middle.mp3",
path + code.charAt(5) + "_falling.mp3",
path + "verification.mp3",
path + code.charAt(0) + "_middle.mp3",
path + code.charAt(1) + "_middle.mp3",
path + code.charAt(2) + "_middle.mp3",
path + code.charAt(3) + "_middle.mp3",
path + code.charAt(4) + "_middle.mp3",
path + code.charAt(5) + "_falling.mp3",
path + "verification.mp3",
path + code.charAt(0) + "_middle.mp3",
path + code.charAt(1) + "_middle.mp3",
path + code.charAt(2) + "_middle.mp3",
path + code.charAt(3) + "_middle.mp3",
path + code.charAt(4) + "_middle.mp3",
path + code.charAt(5) + "_falling.mp3"))
.build();
}
private String constructUrlForLocale(String baseUrl, String locale) {
if (!baseUrl.endsWith("/")) {
baseUrl += "/";
}
return baseUrl + locale + "/";
}
}