mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-22 00:38:02 +01:00
Break out into a multi-module project
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('_', '/'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
public class InvalidDestinationException extends Exception {
|
||||
public InvalidDestinationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 + "/";
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user