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