Java tutorial
/* * Kontalk XMPP Tigase extension * Copyright (C) 2017 Kontalk Devteam <devteam@kontalk.org> * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU 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 General Public License for more details. * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kontalk.xmppserver; import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber; import org.apache.commons.lang3.StringUtils; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPublicKey; import org.bouncycastle.util.encoders.Hex; import org.kontalk.xmppserver.auth.KontalkAuth; import org.kontalk.xmppserver.pgp.PGPUserID; import org.kontalk.xmppserver.pgp.PGPUtils; import org.kontalk.xmppserver.presence.JDBCPresenceRepository; import org.kontalk.xmppserver.probe.ProbeInfo; import org.kontalk.xmppserver.probe.ProbeListener; import org.kontalk.xmppserver.probe.ProbeManager; import org.kontalk.xmppserver.registration.PhoneNumberVerificationProvider; import org.kontalk.xmppserver.registration.RegistrationRequest; import org.kontalk.xmppserver.registration.VerificationRepository; import org.kontalk.xmppserver.util.Utils; import org.kontalk.xmppserver.x509.X509Utils; import tigase.annotations.TODO; import tigase.auth.mechanisms.SaslEXTERNAL; import tigase.conf.ConfigurationException; import tigase.db.NonAuthUserRepository; import tigase.db.TigaseDBException; import tigase.db.UserExistsException; import tigase.db.UserNotFoundException; import tigase.form.Field; import tigase.form.Form; import tigase.server.Iq; import tigase.server.Packet; import tigase.server.Presence; import tigase.server.XMPPServer; import tigase.server.xmppsession.SessionManager; import tigase.server.xmppsession.SessionManagerHandler; import tigase.stats.StatisticsList; import tigase.util.Base64; import tigase.util.TigaseStringprepException; import tigase.xml.Element; import tigase.xmpp.*; import tigase.xmpp.impl.PresenceSubscription; import tigase.xmpp.impl.roster.RosterAbstract; import tigase.xmpp.impl.roster.RosterFlat; import java.io.IOException; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * jabber:iq:register plugin for Kontalk. * Inspired by the jabber:iq:register Tigase plugin. * @author Daniele Ricci */ @TODO(note = "Support for multiple virtual hosts; Expire private key tokens") public class KontalkIqRegister extends XMPPProcessor implements XMPPProcessorIfc, ProbeListener { private static final String[][] ELEMENTS = { Iq.IQ_QUERY_PATH }; public static final String ID = "kontalk:jabber:iq:register"; private static Logger log = Logger.getLogger(KontalkIqRegister.class.getName()); private static final String[] XMLNSS = { "jabber:iq:register" }; // form XPath and xmlns private static final String IQ_FORM_ELEM_NAME = "x"; private static final String IQ_FORM_XMLNS = "jabber:x:data"; private static final String IQ_FORM_KONTALK_CODE_XMLNS = "http://kontalk.org/protocol/register#code"; private static final String IQ_FORM_KONTALK_PRIVATEKEY_XMLNS = "http://kontalk.org/protocol/register#privatekey"; // account management private static final String IQ_ACCOUNT_ELEM_NAME = "account"; private static final String IQ_ACCOUNT_XMLNS = "http://kontalk.org/protocol/register#account"; private static final String IQ_ACCOUNT_PRIVATEKEY_ELEM_NAME = "privatekey"; private static final String IQ_ACCOUNT_TOKEN_ELEM_NAME = "token"; // form fields private static final String FORM_FIELD_PHONE = "phone"; private static final String FORM_FIELD_CODE = "code"; private static final String FORM_FIELD_PUBLICKEY = "publickey"; private static final String FORM_FIELD_REVOKED = "revoked"; private static final String FORM_FIELD_FORCE = "force"; private static final String FORM_FIELD_FALLBACK = "fallback"; private static final String FORM_FIELD_PRIVATEKEY = "privatekey"; /** Form field to request a specific challenge type. */ private static final String FORM_FIELD_CHALLENGE = "challenge"; private static final Element[] FEATURES = { new Element("register", new String[] { "xmlns" }, new String[] { "http://jabber.org/features/iq-register" }) }; private static final Element[] DISCO_FEATURES = { new Element("feature", new String[] { "var" }, new String[] { "jabber:iq:register" }) }; private static final String ERROR_INVALID_CODE = "Invalid verification code."; private static final String ERROR_MALFORMED_REQUEST = "Please provide either a phone number or a public key and a verification code."; private static final String ERROR_INVALID_REVOKED = "Invalid revocation key."; private static final String ERROR_INVALID_PUBKEY = "Invalid public key."; private static final String ERROR_INVALID_PRIVKEY_TOKEN = "Invalid private key token."; private static final String NODE_PRIVATEKEY = "kontalk/privatekey"; private static final String KEY_PRIVATEKEY_DATA = "keydata"; private static final String KEY_PRIVATEKEY_JID = "jid"; /** Default user expire time in seconds. */ private static final long DEF_EXPIRE_SECONDS = TimeUnit.DAYS.toSeconds(30); private static final int PRIVATE_KEY_ID_LEN = 40; /** Pattern for connectionId resource: 127.0.0.1_5222_127.0.0.1_38492 */ // FIXME this is valid only for IPv4 private static final Pattern PATTERN_CLIENT_ADDRESS = Pattern.compile("^.*_.*_(.*)_.*$"); /** Number of registration attempts to consider to be throttling. After this, timestamps will be checked. */ private static final int THROTTLING_ATTEMPTS_THRESHOLD = 3; /** Minimum delay for coming out of throttling. This is used after the number of attempts is greater than {@link #THROTTLING_ATTEMPTS_THRESHOLD}. */ private static final long THROTTLING_MIN_DELAY = TimeUnit.MINUTES.toMillis(30); private static final RosterFlat rosterUtil = new RosterFlat(); private static final SessionManagerHandler loginHandler = new SessionManagerHandler() { @Override public JID getComponentId() { return null; } @Override public void handleLogin(BareJID userId, XMPPResourceConnection conn) { } @Override public void handleDomainChange(String domain, XMPPResourceConnection conn) { } @Override public void handleLogout(BareJID userId, XMPPResourceConnection conn) { } @Override public void handlePresenceSet(XMPPResourceConnection conn) { } @Override public void handleResourceBind(XMPPResourceConnection conn) { } @Override public boolean isLocalDomain(String domain, boolean includeComponents) { return false; } }; private Map<String, PhoneNumberVerificationProvider> providers; // these two are actually references to instances stored in providers map above // if default-provider and fallback-provider are not defined in config, the first and second provider will be used // as default and fallback (if any) private PhoneNumberVerificationProvider defaultProvider; private PhoneNumberVerificationProvider fallbackProvider; private long statsRegistrationAttempts; private long statsRegisteredUsers; private long statsInvalidRegistrations; private Map<BareJID, RegistrationRequest> requests; /** Stores useful data for detecting registration throttling. */ private static final class LastRegisterRequest { /** Number of requests so far. */ public int attempts; /** Timestamp of last request. */ public long lastTimestamp; } private Map<String, LastRegisterRequest> throttlingRequests; private JDBCPresenceRepository userRepository = new JDBCPresenceRepository(); @Override public String id() { return ID; } @Override public void init(Map<String, Object> settings) throws TigaseDBException { requests = new HashMap<>(); throttlingRequests = new HashMap<>(); // registration providers providers = new LinkedHashMap<>(); String[] providersList = (String[]) settings.get("providers"); if (providersList == null || providersList.length == 0) throw new TigaseDBException("No providers configured"); String defaultProviderName = (String) settings.get("default-provider"); String fallbackProviderName = (String) settings.get("fallback-provider"); for (String providerStr : providersList) { String[] providerDef = providerStr.split("="); if (providerDef.length != 2) throw new TigaseDBException("Bad provider definition: " + providerStr); String providerName = providerDef[0]; String providerClassName = providerDef[1]; try { @SuppressWarnings("unchecked") Class<? extends PhoneNumberVerificationProvider> providerClass = (Class<? extends PhoneNumberVerificationProvider>) Class .forName(providerClassName); PhoneNumberVerificationProvider provider = providerClass.newInstance(); provider.init(getPrefixedSettings(settings, providerName + "-")); // init was successful providers.put(providerName, provider); if (defaultProviderName != null) { // this is the default provider if (defaultProviderName.equals(providerName)) defaultProvider = provider; } else if (defaultProvider == null) { // no default provider defined, use the first one found defaultProvider = provider; } if (fallbackProviderName != null) { // this is the fallback provider if (fallbackProviderName.equals(providerName)) fallbackProvider = provider; } else if (fallbackProvider == null && defaultProvider != null) { // no fallback provider defined and default provider already set // use the second provider found fallbackProvider = provider; } } catch (ClassNotFoundException e) { throw new TigaseDBException("Provider class not found: " + providerClassName); } catch (InstantiationException | IllegalAccessException e) { throw new TigaseDBException("Unable to create provider instance for " + providerClassName); } catch (ConfigurationException e) { throw new TigaseDBException("configuration error", e); } } // user repository for periodical purge of old users String uri = (String) settings.get("db-uri"); userRepository.initRepository(uri, null); // delete expired users once a day long timeout = TimeUnit.DAYS.toMillis(1); Timer taskTimer = new Timer(ID + " tasks", true); taskTimer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { try { if (log.isLoggable(Level.FINEST)) { log.finest("Purging expired users."); } // TODO seconds should be in configuration List<BareJID> users = userRepository.getExpiredUsers(DEF_EXPIRE_SECONDS); for (BareJID user : users) { removeUser(user); } } catch (TigaseDBException e) { log.log(Level.WARNING, "error purging expired users", e); } } }, timeout, timeout); } private Map<String, Object> getPrefixedSettings(Map<String, Object> settings, String prefix) { Map<String, Object> out = new HashMap<>(settings); for (Map.Entry<String, Object> entry : settings.entrySet()) { String key = entry.getKey(); if (key.startsWith(prefix)) { out.put(key.substring(prefix.length()), entry.getValue()); out.remove(key); } } return out; } private PhoneNumberVerificationProvider getChallengedProvider(String challenge) { for (PhoneNumberVerificationProvider provider : providers.values()) { if (provider.getChallengeType().equals(challenge)) return provider; } return null; } private PhoneNumberVerificationProvider getSupportedProvider(RegistrationRequest request) { for (PhoneNumberVerificationProvider provider : providers.values()) { if (provider.supportsRequest(request)) return provider; } return null; } private void removeUser(BareJID jid) throws TigaseDBException { if (log.isLoggable(Level.FINE)) { log.log(Level.FINE, "Deleting user {0}", jid); } try { // send unsubscribed to all contacts unsubscribeFromRoster(jid); } catch (NotAuthorizedException e) { log.log(Level.WARNING, "unable to unsubscribe from roster of " + jid, e); } userRepository.removeUser(jid); } /** Sends an unsubscribed stanza to all user in the given user's roster. */ private void unsubscribeFromRoster(BareJID jid) throws NotAuthorizedException, TigaseDBException { // prepare session object for retrieving the roster XMPPSession parentSession = new XMPPSession(jid.getLocalpart()); XMPPResourceConnection session = new XMPPResourceConnection(JID.jidInstanceNS(jid, "internal"), userRepository, userRepository, loginHandler); SessionManager sessMan = (SessionManager) XMPPServer.getComponent("sess-man"); try { session.setDomain(sessMan.getVHostItem(jid.getDomain())); session.setParentSession(parentSession); session.authorizeJID(jid, false); } catch (TigaseStringprepException e) { throw new TigaseDBException("Unable to authorize session", e); } JID[] buddies = rosterUtil.getBuddies(session, RosterAbstract.FROM_SUBSCRIBED); if (buddies != null && buddies.length > 0) { // we are not in a processing queue so we need direct access to the SessionManager for (JID user : buddies) { try { Packet packet = Packet.packetInstance(Presence.ELEM_NAME, jid.toString(), user.getBareJID().toString(), StanzaType.unsubscribed); packet.setXMLNS(PresenceSubscription.CLIENT_XMLNS); sessMan.addOutPacket(packet); } catch (TigaseStringprepException e) { log.log(Level.WARNING, "Unable to create unsubscription packet", e); } } } } @Override public void process(Packet packet, XMPPResourceConnection session, NonAuthUserRepository repo, Queue<Packet> results, Map<String, Object> settings) throws XMPPException { if (log.isLoggable(Level.FINEST)) { log.finest("Processing packet: " + packet.toString()); } if (session == null) { if (log.isLoggable(Level.FINEST)) { log.finest("Session is null, ignoring"); } return; } BareJID id = session.getDomainAsJID().getBareJID(); if (packet.getStanzaTo() != null) { id = packet.getStanzaTo().getBareJID(); } try { if ((packet.getPacketFrom() != null) && packet.getPacketFrom().equals(session.getConnectionId()) && (!session.isAuthorized() || (session.isUserId(id) || session.isLocalDomain(id.toString(), false)))) { Element request = packet.getElement(); if (!session.isAuthorized()) { if (!session.getDomain().isRegisterEnabled()) { results.offer(Authorization.NOT_ALLOWED.getResponseMessage(packet, "Registration is not allowed for this domain.", true)); ++statsInvalidRegistrations; return; } } StanzaType type = packet.getType(); switch (type) { case set: { // FIXME handle all the cases according to the FORM_TYPE Element query = request.getChild(Iq.QUERY_NAME, XMLNSS[0]); Element formElement = (query != null) ? query.getChild(IQ_FORM_ELEM_NAME, IQ_FORM_XMLNS) : null; if (formElement != null) { Form form = new Form(formElement); // phone number: verification code String phone = form.getAsString(FORM_FIELD_PHONE); if (!session.isAuthorized() && phone != null) { boolean force; try { force = form.getAsBoolean(FORM_FIELD_FORCE); } catch (NullPointerException e) { force = false; } boolean fallback; try { fallback = form.getAsBoolean(FORM_FIELD_FALLBACK); } catch (NullPointerException e) { fallback = false; } String challenge; try { challenge = form.getAsString(FORM_FIELD_CHALLENGE); } catch (NullPointerException e) { challenge = null; } Packet response = registerPhone(session, packet, phone, force, fallback, challenge, results); statsRegistrationAttempts++; packet.processedBy(ID); if (response != null) results.offer(response); break; } // verification code: key submission String code = form.getAsString(FORM_FIELD_CODE); // get public key block from client certificate byte[] publicKeyData = getPublicKey(session); if (!session.isAuthorized()) { // load public key PGPPublicKey key = loadPublicKey(publicKeyData); // verify user id BareJID jid = verifyPublicKey(session, key); if (verifyCode(session, jid, code)) { byte[] signedKey = signPublicKey(session, publicKeyData); Packet response = register(session, packet, jid, key.getFingerprint(), signedKey); statsRegisteredUsers++; packet.processedBy(ID); results.offer(response); } else { // invalid verification code results.offer(Authorization.BAD_REQUEST.getResponseMessage(packet, ERROR_INVALID_CODE, true)); } // clear throttling clearThrottling(jid, session.getConnectionId()); break; } if (session.isAuthorized()) { // private key storage String privateKey = form.getAsString(FORM_FIELD_PRIVATEKEY); if (privateKey != null) { Packet response = storePrivateKey(session, repo, packet, privateKey); packet.processedBy(ID); results.offer(response); break; } else { // public key + revoked key: key rollover or upgrade from legacy String oldFingerprint; try { oldFingerprint = KontalkAuth.getUserFingerprint(session); } catch (UserNotFoundException e) { oldFingerprint = null; log.log(Level.INFO, "user not found: {0}", session); } if (oldFingerprint != null) { // do not use public key from certificate publicKeyData = null; // user already has a key, check if revoked key fingerprint matches String publicKey = form.getAsString(FORM_FIELD_PUBLICKEY); String revoked = form.getAsString(FORM_FIELD_REVOKED); if (publicKey != null && revoked != null) { publicKeyData = Base64.decode(publicKey); byte[] revokedData = Base64.decode(revoked); KontalkKeyring keyring = getKeyring(session); if (!keyring.revoked(revokedData, oldFingerprint)) { // invalid revocation key log.log(Level.INFO, "Invalid revocation key for user {0}", session.getBareJID()); results.offer(Authorization.FORBIDDEN.getResponseMessage(packet, ERROR_INVALID_REVOKED, false)); break; } } } // user has no key or revocation key was fine, accept the new key if (publicKeyData != null) { rolloverContinue(session, publicKeyData, packet, results); break; } } } } // bad request results.offer( Authorization.BAD_REQUEST.getResponseMessage(packet, ERROR_MALFORMED_REQUEST, true)); break; } case get: { Element query = request.getChild(Iq.QUERY_NAME, XMLNSS[0]); Element account = query.getChild(IQ_ACCOUNT_ELEM_NAME, IQ_ACCOUNT_XMLNS); if (account != null) { String token = account.getChildCData(new String[] { IQ_ACCOUNT_ELEM_NAME, IQ_ACCOUNT_PRIVATEKEY_ELEM_NAME, IQ_ACCOUNT_TOKEN_ELEM_NAME }); if (StringUtils.isNotEmpty(token)) { Packet response = retrievePrivateKey(session, repo, packet, token); response.processedBy(ID); results.offer(response); break; } } // instructions form results.offer(buildInstructionsForm(packet)); break; } default: results.offer(Authorization.BAD_REQUEST.getResponseMessage(packet, "Message type is incorrect", true)); break; } } else { if (session.isUserId(id)) { // It might be a registration request from transport for // example... Packet pack_res = packet.copyElementOnly(); pack_res.setPacketTo(session.getConnectionId()); results.offer(pack_res); } else { results.offer(packet.copyElementOnly()); } } } catch (NotAuthorizedException e) { results.offer(Authorization.NOT_AUTHORIZED.getResponseMessage(packet, "You are not authorized to change registration settings.\n" + e.getMessage(), true)); } catch (TigaseDBException e) { log.warning("Database problem: " + e); results.offer(Authorization.INTERNAL_SERVER_ERROR.getResponseMessage(packet, "Database access problem, please contact administrator.", true)); } // generated from PGP catch (IOException e) { log.warning("Unknown error: " + e); results.offer(Authorization.INTERNAL_SERVER_ERROR.getResponseMessage(packet, "Internal PGP error. Please contact administrator.", true)); } catch (PGPException e) { e.printStackTrace(System.err); log.warning("PGP problem: " + e); results.offer(Authorization.BAD_REQUEST.getResponseMessage(packet, ERROR_INVALID_PUBKEY, true)); } } private Packet buildInstructionsForm(Packet packet) { Element query = new Element("query", new String[] { "xmlns" }, XMLNSS); Form form = new Form("form", null, null); form.addField(Field.fieldHidden("FORM_TYPE", XMLNSS[0])); Field phone = Field.fieldTextSingle("phone", null, "Phone number"); phone.setRequired(true); form.addField(phone); form.addField(Field.fieldTextSingle("force", null, "Force registration")); form.addField(Field.fieldBoolean("fallback", null, "Fallback")); form.addField(Field.fieldListSingle("challenge", null, "Challenge type", new String[] { "Verification code", "Missed call", "Caller ID", }, new String[] { PhoneNumberVerificationProvider.CHALLENGE_PIN, PhoneNumberVerificationProvider.CHALLENGE_MISSED_CALL, PhoneNumberVerificationProvider.CHALLENGE_CALLER_ID, })); query.addChild(form.getElement()); return packet.okResult(query, 0); } private byte[] getPublicKey(XMPPResourceConnection session) throws PGPException, IOException { Certificate peerCert = (Certificate) session.getSessionData(SaslEXTERNAL.PEER_CERTIFICATE_KEY); if (peerCert instanceof X509Certificate) { return X509Utils.getMatchingPublicKey((X509Certificate) peerCert); } throw new PGPException("client certificate not found"); } private Packet registerPhone(XMPPResourceConnection session, Packet packet, String phoneInput, boolean force, boolean fallback, String challenge, Queue<Packet> results) throws PacketErrorTypeException, TigaseDBException, NoConnectionIdException { String phone; try { phone = formatPhoneNumber(phoneInput); } catch (NumberParseException e) { // bad number statsInvalidRegistrations++; log.log(Level.INFO, "Invalid phone number: {0}", phoneInput); return Authorization.BAD_REQUEST.getResponseMessage(packet, "Bad phone number.", true); } log.log(Level.FINEST, "Registering phone number: {0}", phone); BareJID jid = KontalkAuth.toBareJID(phone, session.getDomainAsJID().getDomain()); if (!force) { // probe user in the network RegistrationInfo regInfo = new RegistrationInfo(); regInfo.packet = packet; regInfo.jid = jid; regInfo.phone = phone; regInfo.connectionId = session.getConnectionId(); regInfo.fallback = fallback; regInfo.challenge = challenge; String probeId = ProbeManager.getInstance().probe(jid, this, regInfo, results); if (probeId == null) { if (log.isLoggable(Level.FINE)) { log.log(Level.FINE, "user found locally: {0}", jid); } // notify the user immediately return errorUserExists(packet); } else { if (log.isLoggable(Level.FINER)) { log.log(Level.FINER, "probe sent for {0} with id {1}", new String[] { jid.toString(), probeId }); } // do nothing and wait for the probe result return null; } } else { return startVerification(session.getDomainAsJID().getDomain(), packet, session.getConnectionId(), jid, phone, fallback, challenge); } } private Packet startVerification(String domain, Packet packet, JID connectionId, BareJID jid, String phone, boolean fallback, String challenge) throws TigaseDBException, PacketErrorTypeException { PhoneNumberVerificationProvider provider = null; if (challenge != null) { // client request a specific challenge provider = getChallengedProvider(challenge); } if (provider == null) { // no provider with request challenge or no challenge requested // fall back to default or fallback if requested provider = fallback && fallbackProvider != null ? fallbackProvider : defaultProvider; } return startVerification(domain, packet, connectionId, jid, phone, provider); } private Packet startVerification(String domain, Packet packet, JID connectionId, BareJID jid, String phone, PhoneNumberVerificationProvider provider) throws TigaseDBException, PacketErrorTypeException { try { if (isThrottlingPhone(jid) || isThrottlingClient(connectionId)) { throw new VerificationRepository.AlreadyRegisteredException(); } String senderId = null; RegistrationRequest request = provider.startVerification(domain, phone); if (request != null) { requests.put(jid, request); senderId = request.getSenderId(); } else { requests.remove(jid); } if (senderId == null) senderId = provider.getSenderId(); // enable going to fallback only if we are not already falling back (and we have a fallback provider) return packet.okResult(prepareSMSResponseForm(senderId, provider, fallbackProvider != null && provider != fallbackProvider), 0); } catch (IOException e) { // some kind of error statsInvalidRegistrations++; log.log(Level.WARNING, "Failed verify number for: {0} ({1})", new Object[] { jid, e.getMessage() }); if (fallbackProvider != null && provider != fallbackProvider) { // we might try with the fallback provider now return startVerification(domain, packet, connectionId, jid, phone, fallbackProvider); } else { return Authorization.NOT_ACCEPTABLE.getResponseMessage(packet, "Unable to verify number.", true); } } catch (VerificationRepository.AlreadyRegisteredException e) { // throttling registrations statsInvalidRegistrations++; log.log(Level.INFO, "Throttling registration for: {0}", jid); return packet.errorResult("wait", Authorization.SERVICE_UNAVAILABLE.getErrorCode(), Authorization.SERVICE_UNAVAILABLE.getCondition(), "Too many attempts.", true); } } /** Returns true if the phone number has been trying to register too many times. */ private boolean isThrottlingPhone(BareJID jid) { return isThrottling(jid.toString()); } private boolean isThrottlingClient(JID connectionId) { String host = extractHostFromConnectionId(connectionId); if (StringUtils.isNotEmpty(host)) { return isThrottling(host); } return false; } private boolean isThrottling(String id) { long now = System.currentTimeMillis(); LastRegisterRequest request = throttlingRequests.get(id); try { if (request != null) { if (request.attempts >= THROTTLING_ATTEMPTS_THRESHOLD) { return (now - request.lastTimestamp) < THROTTLING_MIN_DELAY; } else { request.attempts++; return false; } } else { request = new LastRegisterRequest(); request.attempts = 1; throttlingRequests.put(id, request); return false; } } finally { request.lastTimestamp = now; } } private void clearThrottling(BareJID jid, JID connectionId) { throttlingRequests.remove(jid.toString()); String host = extractHostFromConnectionId(connectionId); if (StringUtils.isNotEmpty(host)) { throttlingRequests.remove(host); } } private String extractHostFromConnectionId(JID connectionId) { String hostInfo = connectionId.getResource(); if (hostInfo != null) { Matcher match = PATTERN_CLIENT_ADDRESS.matcher(hostInfo); if (match.matches() && match.groupCount() > 0) { return match.group(1); } } return null; } private Element prepareSMSResponseForm(String from, PhoneNumberVerificationProvider provider, boolean canFallback) { Element query = new Element("query", new String[] { "xmlns" }, XMLNSS); query.addChild(new Element("instructions", provider.getAckInstructions())); Form form = new Form("form", null, null); form.addField(Field.fieldHidden("FORM_TYPE", XMLNSS[0])); form.addField(Field.fieldTextSingle("from", from, "Sender ID")); form.addField(Field.fieldTextSingle("challenge", provider.getChallengeType(), "Challenge type")); if (canFallback) form.addField(Field.fieldBoolean("can-fallback", true, "Whether client can fallback to another method or not")); String brandImageVector = provider.getBrandImageVector(); if (brandImageVector != null) { form.addField(Field.fieldTextSingle("brand-image-vector", brandImageVector, "Brand logo (vector)")); String brandImageSmall = provider.getBrandImageSmall(); if (brandImageSmall != null) form.addField(Field.fieldTextSingle("brand-image-small", brandImageSmall, "Brand logo (small)")); String brandImageMedium = provider.getBrandImageMedium(); if (brandImageMedium != null) form.addField(Field.fieldTextSingle("brand-image-medium", brandImageMedium, "Brand logo (medium)")); String brandImageLarge = provider.getBrandImageLarge(); if (brandImageLarge != null) form.addField(Field.fieldTextSingle("brand-image-large", brandImageLarge, "Brand logo (large)")); String brandImageHighDef = provider.getBrandImageHighDef(); if (brandImageHighDef != null) form.addField(Field.fieldTextSingle("brand-image-hd", brandImageHighDef, "Brand logo (HD)")); String brandLink = provider.getBrandLink(); if (brandLink != null) { form.addField(Field.fieldTextSingle("brand-link", brandLink, "Brand link")); } } query.addChild(form.getElement()); return query; } private Packet register(XMPPResourceConnection session, Packet packet, BareJID jid, byte[] fingerprint, byte[] publicKey) throws TigaseDBException { try { // delete old user first if it exists // this will delete any personal information of the previous phone number owner removeUser(jid); } catch (TigaseDBException e) { // user doesn't exist? // don't really care... } try { userRepository.addUser(jid); } catch (UserExistsException e) { // user already exists } KontalkAuth.setUserFingerprint(session, jid, Hex.toHexString(fingerprint).toUpperCase()); return packet.okResult(prepareRegisteredResponseForm(publicKey), 0); } private Element prepareRegisteredResponseForm(byte[] publicKey) { Element query = new Element("query", new String[] { "xmlns" }, XMLNSS); Form form = new Form("form", null, null); form.addField(Field.fieldHidden("FORM_TYPE", IQ_FORM_KONTALK_CODE_XMLNS)); form.addField(Field.fieldTextSingle("publickey", Base64.encode(publicKey), "Signed public key")); query.addChild(form.getElement()); return query; } private void rolloverContinue(XMPPResourceConnection session, byte[] publicKeyData, Packet packet, Queue<Packet> results) throws IOException, PGPException, TigaseDBException, PacketErrorTypeException, NotAuthorizedException { PGPPublicKey key = loadPublicKey(publicKeyData); // verify user id BareJID jid = verifyPublicKey(session, key); if (jid != null) { byte[] signedKey = signPublicKey(session, publicKeyData); Packet response = register(session, packet, jid, key.getFingerprint(), signedKey); // send signed key in response packet.processedBy(ID); results.offer(response); } else { results.offer(Authorization.FORBIDDEN.getResponseMessage(packet, ERROR_INVALID_PUBKEY, false)); } } private Packet storePrivateKey(XMPPResourceConnection session, NonAuthUserRepository repo, Packet packet, String privateKeyB64) throws NotAuthorizedException, TigaseDBException { BareJID domain = session.getDomainAsJID().getBareJID(); String token = Utils.generateRandomNumericString(PRIVATE_KEY_ID_LEN).toUpperCase(); repo.putDomainTempData(domain, NODE_PRIVATEKEY + "/" + token, KEY_PRIVATEKEY_DATA, privateKeyB64); repo.putDomainTempData(domain, NODE_PRIVATEKEY + "/" + token, KEY_PRIVATEKEY_JID, session.getBareJID().toString()); return packet.okResult(preparePrivateKeyStoredResponseForm(token), 0); } private Element preparePrivateKeyStoredResponseForm(String token) { Element query = new Element("query", new String[] { "xmlns" }, XMLNSS); Form form = new Form("form", null, null); form.addField(Field.fieldHidden("FORM_TYPE", IQ_FORM_KONTALK_PRIVATEKEY_XMLNS)); form.addField(Field.fieldTextSingle("token", token, "Private key identification token")); query.addChild(form.getElement()); return query; } private Packet retrievePrivateKey(XMPPResourceConnection session, NonAuthUserRepository repo, Packet packet, String token) throws NotAuthorizedException, TigaseDBException, PacketErrorTypeException { BareJID domain = session.getDomainAsJID().getBareJID(); // unique node in domain data formed by "privatekey" + the token String privateKeyData = repo.getDomainTempData(domain, NODE_PRIVATEKEY + "/" + token, KEY_PRIVATEKEY_DATA, null); String userId = repo.getDomainTempData(domain, NODE_PRIVATEKEY + "/" + token, KEY_PRIVATEKEY_JID, null); if (StringUtils.isEmpty(privateKeyData) || StringUtils.isEmpty(userId)) return Authorization.NOT_AUTHORIZED.getResponseMessage(packet, ERROR_INVALID_PRIVKEY_TOKEN, false); // load public key from keyring, we'll return it to the client as well String fingerprint = KontalkAuth.getUserFingerprint(session, BareJID.bareJIDInstanceNS(userId)); if (fingerprint == null) return Authorization.NOT_AUTHORIZED.getResponseMessage(packet, ERROR_INVALID_PRIVKEY_TOKEN, false); String publicKeyData; try { KontalkKeyring keyring = KontalkKeyring.getInstance(domain.toString()); byte[] _publicKeyData = keyring.exportKey(fingerprint); publicKeyData = Base64.encode(_publicKeyData); } catch (Exception e) { log.log(Level.WARNING, "Public key for user not found or not valid: " + userId, e); return Authorization.NOT_AUTHORIZED.getResponseMessage(packet, ERROR_INVALID_PRIVKEY_TOKEN, false); } // this is supposed to be a one-time request repo.removeDomainTempData(domain, NODE_PRIVATEKEY + "/" + token, KEY_PRIVATEKEY_DATA); repo.removeDomainTempData(domain, NODE_PRIVATEKEY + "/" + token, KEY_PRIVATEKEY_JID); return packet.okResult(preparePrivateKeyResponseForm(privateKeyData, publicKeyData), 0); } private Element preparePrivateKeyResponseForm(String privateKeyData, String publicKeyData) { Element query = new Element("query", new String[] { "xmlns" }, XMLNSS); Element account = new Element(IQ_ACCOUNT_ELEM_NAME, new String[] { "xmlns" }, new String[] { IQ_ACCOUNT_XMLNS }); Element privateKey = new Element(IQ_ACCOUNT_PRIVATEKEY_ELEM_NAME); Element privKeyElem = new Element("private"); Element pubKeyElem = new Element("public"); privKeyElem.setCData(privateKeyData); pubKeyElem.setCData(publicKeyData); privateKey.addChild(privKeyElem); privateKey.addChild(pubKeyElem); account.addChild(privateKey); query.addChild(account); return query; } private Packet errorUserExists(Packet packet) { return packet.errorResult("modify", Authorization.CONFLICT.getErrorCode(), Authorization.CONFLICT.getCondition(), "Another user is registered with the same id.", true); } private String formatPhoneNumber(String phoneInput) throws NumberParseException { PhoneNumberUtil util = PhoneNumberUtil.getInstance(); Phonenumber.PhoneNumber phone = util.parse(phoneInput, null); return util.format(phone, PhoneNumberUtil.PhoneNumberFormat.E164); } private BareJID parseUserID(PGPPublicKey publicKey) throws PGPException { PGPUserID uid = PGPUtils.parseUserID(publicKey); if (uid == null) throw new PGPException("Invalid user id"); return BareJID.bareJIDInstanceNS(uid.getEmail()); } private PGPPublicKey loadPublicKey(byte[] publicKeyData) throws IOException, PGPException { return PGPUtils.getMasterKey(publicKeyData); } private BareJID verifyPublicKey(XMPPResourceConnection session, PGPPublicKey publicKey) throws PGPException, NotAuthorizedException { // TODO import key into gpg for advanced verification BareJID jid = parseUserID(publicKey); if (session.isAuthorized() ? session.getBareJID().equals(jid) : session.getDomainAsJID().toString().equalsIgnoreCase(jid.getDomain())) return jid; throw new PGPException("Invalid email identifier"); } private boolean verifyCode(XMPPResourceConnection session, BareJID jid, String code) throws TigaseDBException, IOException { RegistrationRequest request = requests.get(jid); if (request == null) { // request was lost or doesn't exist return false; } PhoneNumberVerificationProvider prov = getSupportedProvider(request); return prov != null && prov.endVerification(session, request, code); } private byte[] signPublicKey(XMPPResourceConnection session, byte[] publicKeyData) throws IOException, PGPException { KontalkKeyring keyring = getKeyring(session); return keyring.signKey(publicKeyData); } private KontalkKeyring getKeyring(XMPPResourceConnection session) throws IOException, PGPException { return KontalkAuth.getKeyring(session); } @Override public Element[] supDiscoFeatures(XMPPResourceConnection session) { if (log.isLoggable(Level.FINEST) && (session != null)) { log.finest("VHostItem: " + session.getDomain()); } if ((session != null) && session.getDomain().isRegisterEnabled()) { return DISCO_FEATURES; } else { return null; } } @Override public String[][] supElementNamePaths() { return ELEMENTS; } @Override public String[] supNamespaces() { return XMLNSS; } @Override public Element[] supStreamFeatures(XMPPResourceConnection session) { if (log.isLoggable(Level.FINEST) && (session != null)) { log.finest("VHostItem: " + session.getDomain()); } if ((session != null) && session.getDomain().isRegisterEnabled()) { return FEATURES; } else { return null; } } @Override public void getStatistics(StatisticsList list) { super.getStatistics(list); list.add(getComponentInfo().getName(), "Registration attempts", statsRegistrationAttempts, Level.INFO); list.add(getComponentInfo().getName(), "Registered users", statsRegisteredUsers, Level.INFO); list.add(getComponentInfo().getName(), "Invalid registrations", statsInvalidRegistrations, Level.INFO); } @Override public boolean onProbeResult(ProbeInfo info, Object userData, Queue<Packet> results) { if (log.isLoggable(Level.FINEST)) { log.finest("probe result: " + info); } RegistrationInfo regInfo = (RegistrationInfo) userData; Set<BareJID> storage = info.getStorage(); if (storage != null && storage.size() > 0) { results.offer(errorUserExists(regInfo.packet)); } else { SessionManager sm = (SessionManager) XMPPServer.getComponent("sess-man"); try { Packet result = startVerification(sm.getDefVHostItem().getDomain(), regInfo.packet, regInfo.connectionId, regInfo.jid, regInfo.phone, regInfo.fallback, regInfo.challenge); if (result != null) { result.setPacketTo(regInfo.connectionId); results.offer(result); } } catch (Exception e) { log.log(Level.WARNING, "error starting verification", e); // TODO notify client } } return true; } private static final class RegistrationInfo { Packet packet; BareJID jid; String phone; JID connectionId; boolean fallback; String challenge; } }