RegistrationManager.java

  1package eu.siacs.conversations.xmpp.manager;
  2
  3import android.content.Context;
  4import android.graphics.Bitmap;
  5import android.graphics.BitmapFactory;
  6import android.util.Log;
  7import android.util.Patterns;
  8import androidx.annotation.NonNull;
  9import com.google.common.base.Optional;
 10import com.google.common.collect.ImmutableMap;
 11import com.google.common.collect.Iterables;
 12import com.google.common.util.concurrent.Futures;
 13import com.google.common.util.concurrent.ListenableFuture;
 14import com.google.common.util.concurrent.MoreExecutors;
 15import eu.siacs.conversations.Config;
 16import eu.siacs.conversations.entities.Account;
 17import eu.siacs.conversations.services.XmppConnectionService;
 18import eu.siacs.conversations.xml.Namespace;
 19import eu.siacs.conversations.xmpp.XmppConnection;
 20import im.conversations.android.xmpp.IqErrorException;
 21import im.conversations.android.xmpp.model.data.Data;
 22import im.conversations.android.xmpp.model.error.Condition;
 23import im.conversations.android.xmpp.model.oob.OutOfBandData;
 24import im.conversations.android.xmpp.model.pars.PreAuth;
 25import im.conversations.android.xmpp.model.register.Instructions;
 26import im.conversations.android.xmpp.model.register.Password;
 27import im.conversations.android.xmpp.model.register.Register;
 28import im.conversations.android.xmpp.model.register.Remove;
 29import im.conversations.android.xmpp.model.register.Username;
 30import im.conversations.android.xmpp.model.stanza.Iq;
 31import java.util.Arrays;
 32import java.util.List;
 33import java.util.regex.Matcher;
 34import okhttp3.HttpUrl;
 35
 36public class RegistrationManager extends AbstractManager {
 37
 38    public RegistrationManager(XmppConnectionService context, XmppConnection connection) {
 39        super(context, connection);
 40    }
 41
 42    public ListenableFuture<Void> setPassword(final String password) {
 43        final var account = getAccount();
 44        final var iq = new Iq(Iq.Type.SET);
 45        iq.setTo(account.getJid().getDomain());
 46        final var register = iq.addExtension(new Register());
 47        register.addUsername(account.getJid().getLocal());
 48        register.addPassword(password);
 49        return Futures.transform(
 50                connection.sendIqPacket(iq),
 51                r -> {
 52                    account.setPassword(password);
 53                    account.setOption(Account.OPTION_MAGIC_CREATE, false);
 54                    getDatabase().updateAccount(account);
 55                    return null;
 56                },
 57                MoreExecutors.directExecutor());
 58    }
 59
 60    public ListenableFuture<Void> register() {
 61        final var account = getAccount();
 62        final var iq = new Iq(Iq.Type.SET);
 63        iq.setTo(account.getJid().getDomain());
 64        final var register = iq.addExtension(new Register());
 65        register.addUsername(account.getJid().getLocal());
 66        register.addPassword(account.getPassword());
 67        final ListenableFuture<Void> future =
 68                Futures.transform(
 69                        connection.sendIqPacket(iq, true),
 70                        result -> null,
 71                        MoreExecutors.directExecutor());
 72        return Futures.catchingAsync(
 73                future,
 74                IqErrorException.class,
 75                ex ->
 76                        Futures.immediateFailedFuture(
 77                                new RegistrationFailedException(ex.getResponse())),
 78                MoreExecutors.directExecutor());
 79    }
 80
 81    public ListenableFuture<Void> register(final Data data, final String ocr) {
 82        final var account = getAccount();
 83        final var submission =
 84                data.submit(
 85                        ImmutableMap.of(
 86                                "username",
 87                                account.getJid().getLocal(),
 88                                "password",
 89                                account.getPassword(),
 90                                "ocr",
 91                                ocr));
 92        final var iq = new Iq(Iq.Type.SET);
 93        final var register = iq.addExtension(new Register());
 94        register.addExtension(submission);
 95        final ListenableFuture<Void> future =
 96                Futures.transform(
 97                        connection.sendIqPacket(iq, true),
 98                        result -> null,
 99                        MoreExecutors.directExecutor());
100        return Futures.catchingAsync(
101                future,
102                IqErrorException.class,
103                ex ->
104                        Futures.immediateFailedFuture(
105                                new RegistrationFailedException(ex.getResponse())),
106                MoreExecutors.directExecutor());
107    }
108
109    public ListenableFuture<Void> unregister() {
110        final var account = getAccount();
111        final var iq = new Iq(Iq.Type.SET);
112        iq.setTo(account.getJid().getDomain());
113        final var register = iq.addExtension(new Register());
114        register.addExtension(new Remove());
115        return Futures.transform(
116                connection.sendIqPacket(iq), r -> null, MoreExecutors.directExecutor());
117    }
118
119    public ListenableFuture<Registration> getRegistration() {
120        final var account = getAccount();
121        final var iq = new Iq(Iq.Type.GET);
122        iq.setTo(account.getDomain());
123        iq.addExtension(new Register());
124        final var future = connection.sendIqPacket(iq, true);
125        return Futures.transformAsync(
126                future,
127                result -> {
128                    final var register = result.getExtension(Register.class);
129                    if (register == null) {
130                        throw new IllegalStateException(
131                                "Server did not include register in response");
132                    }
133                    if (register.hasExtension(Username.class)
134                            && register.hasExtension(Password.class)) {
135                        return Futures.immediateFuture(new SimpleRegistration());
136                    }
137
138                    // find bits of binary and get captcha from there
139
140                    final var data = register.getExtension(Data.class);
141                    // note that the captcha namespace is incorrect here. That namespace is only
142                    // used in message challenges. ejabberd uses the incorrect namespace though
143                    if (data != null
144                            && Arrays.asList(Namespace.REGISTER, Namespace.CAPTCHA)
145                                    .contains(data.getFormType())) {
146                        return getExtendedRegistration(register, data);
147                    }
148                    final var oob = register.getExtension(OutOfBandData.class);
149                    final var instructions = register.getExtension(Instructions.class);
150                    final String instructionsText =
151                            instructions == null ? null : instructions.getContent();
152                    final String redirectUrl = oob == null ? null : oob.getURL();
153                    if (redirectUrl != null) {
154                        return Futures.immediateFuture(RedirectRegistration.ifValid(redirectUrl));
155                    }
156                    if (instructionsText != null) {
157                        final Matcher matcher = Patterns.WEB_URL.matcher(instructionsText);
158                        if (matcher.find()) {
159                            final String instructionsUrl =
160                                    instructionsText.substring(matcher.start(), matcher.end());
161                            return Futures.immediateFuture(
162                                    RedirectRegistration.ifValid(instructionsUrl));
163                        }
164                    }
165                    throw new IllegalStateException("No supported registration method found");
166                },
167                MoreExecutors.directExecutor());
168    }
169
170    private ListenableFuture<Registration> getExtendedRegistration(
171            final Register register, final Data data) {
172        final var ocr = data.getFieldByName("ocr");
173        if (ocr == null) {
174            throw new IllegalArgumentException("Missing OCR form field");
175        }
176        final var ocrMedia = ocr.getMedia();
177        if (ocrMedia == null) {
178            throw new IllegalArgumentException("OCR form field missing media");
179        }
180        final var uris = ocrMedia.getUris();
181        final var bobUri = Iterables.find(uris, u -> "cid".equals(u.getScheme()), null);
182        final Optional<im.conversations.android.xmpp.model.bob.Data> bob;
183        if (bobUri != null) {
184            bob = im.conversations.android.xmpp.model.bob.Data.get(register, bobUri.getPath());
185        } else {
186            bob = Optional.absent();
187        }
188        if (bob.isPresent()) {
189            final var bytes = bob.get().asBytes();
190            final Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
191            return Futures.immediateFuture(new ExtendedRegistration(bitmap, data));
192        }
193        final var captchaFallbackUrl = data.getValue("captcha-fallback-url");
194        if (captchaFallbackUrl == null) {
195            throw new IllegalStateException("No captcha fallback URL provided");
196        }
197        final var captchFallbackHttpUrl = HttpUrl.parse(captchaFallbackUrl);
198        Log.d(Config.LOGTAG, "fallback url: " + captchFallbackHttpUrl);
199        throw new IllegalStateException("Not implemented");
200    }
201
202    public ListenableFuture<Registration> getRegistration(final String token) {
203        final var preAuthentication = sendPreAuthentication(token);
204        final var caught =
205                Futures.catchingAsync(
206                        preAuthentication,
207                        IqErrorException.class,
208                        ex -> {
209                            final var error = ex.getError();
210                            final var condition = error == null ? null : error.getCondition();
211                            if (condition instanceof Condition.ItemNotFound) {
212                                return Futures.immediateFailedFuture(
213                                        new InvalidTokenException(ex.getResponse()));
214                            } else {
215                                return Futures.immediateFuture(ex);
216                            }
217                        },
218                        MoreExecutors.directExecutor());
219        return Futures.transformAsync(
220                caught, v -> getRegistration(), MoreExecutors.directExecutor());
221    }
222
223    public ListenableFuture<Void> sendPreAuthentication(final String token) {
224        final var account = getAccount();
225        final var iq = new Iq(Iq.Type.GET);
226        iq.setTo(account.getJid().getDomain());
227        final var preAuthentication = iq.addExtension(new PreAuth());
228        preAuthentication.setToken(token);
229        final var future = connection.sendIqPacket(iq, true);
230        return Futures.transform(future, result -> null, MoreExecutors.directExecutor());
231    }
232
233    public boolean hasFeature() {
234        return getManager(DiscoManager.class).hasServerFeature(Namespace.REGISTER);
235    }
236
237    public abstract static class Registration {}
238
239    // only requires Username + Password
240    public static class SimpleRegistration extends Registration {}
241
242    // Captcha as shown here: https://xmpp.org/extensions/xep-0158.html#register
243    public static class ExtendedRegistration extends Registration {
244        private final Bitmap captcha;
245        private final Data data;
246
247        public ExtendedRegistration(final Bitmap captcha, final Data data) {
248            this.captcha = captcha;
249            this.data = data;
250        }
251
252        public Bitmap getCaptcha() {
253            return this.captcha;
254        }
255
256        public Data getData() {
257            return this.data;
258        }
259    }
260
261    // Redirection as show here: https://xmpp.org/extensions/xep-0077.html#redirect
262    public static class RedirectRegistration extends Registration {
263        private final HttpUrl url;
264
265        private RedirectRegistration(@NonNull HttpUrl url) {
266            this.url = url;
267        }
268
269        public @NonNull HttpUrl getURL() {
270            return this.url;
271        }
272
273        public static RedirectRegistration ifValid(final String url) {
274            final HttpUrl httpUrl = HttpUrl.parse(url);
275            if (httpUrl != null && httpUrl.isHttps()) {
276                return new RedirectRegistration(httpUrl);
277            }
278            throw new IllegalStateException(
279                    "A URL found the registration instructions is not valid");
280        }
281    }
282
283    public static class InvalidTokenException extends IqErrorException {
284
285        public InvalidTokenException(final Iq response) {
286            super(response);
287        }
288    }
289
290    public static class RegistrationFailedException extends IqErrorException {
291
292        private final List<String> PASSWORD_TOO_WEAK_MESSAGES =
293                Arrays.asList("The password is too weak", "Please use a longer password.");
294
295        public RegistrationFailedException(final Iq response) {
296            super(response);
297        }
298
299        public Account.State asAccountState() {
300            final var error = getError();
301            final var condition = error == null ? null : error.getCondition();
302            if (condition instanceof Condition.Conflict) {
303                return Account.State.REGISTRATION_CONFLICT;
304            } else if (condition instanceof Condition.ResourceConstraint) {
305                return Account.State.REGISTRATION_PLEASE_WAIT;
306            } else if (condition instanceof Condition.NotAcceptable
307                    && PASSWORD_TOO_WEAK_MESSAGES.contains(error.getTextAsString())) {
308                return Account.State.REGISTRATION_PASSWORD_TOO_WEAK;
309            } else {
310                return Account.State.REGISTRATION_FAILED;
311            }
312        }
313    }
314}