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 Log.d(Config.LOGTAG, "could not pre authenticate registration", ex);
210 final var error = ex.getError();
211 final var condition = error == null ? null : error.getCondition();
212 if (condition instanceof Condition.ItemNotFound) {
213 return Futures.immediateFailedFuture(
214 new InvalidTokenException(ex.getResponse()));
215 } else {
216 return Futures.immediateFuture(ex);
217 }
218 },
219 MoreExecutors.directExecutor());
220 return Futures.transformAsync(
221 caught, v -> getRegistration(), MoreExecutors.directExecutor());
222 }
223
224 public ListenableFuture<Void> sendPreAuthentication(final String token) {
225 final var account = getAccount();
226 final var iq = new Iq(Iq.Type.SET);
227 iq.setTo(account.getJid().getDomain());
228 final var preAuthentication = iq.addExtension(new PreAuth());
229 preAuthentication.setToken(token);
230 final var future = connection.sendIqPacket(iq, true);
231 return Futures.transform(future, result -> null, MoreExecutors.directExecutor());
232 }
233
234 public boolean hasFeature() {
235 return getManager(DiscoManager.class).hasServerFeature(Namespace.REGISTER);
236 }
237
238 public abstract static class Registration {}
239
240 // only requires Username + Password
241 public static class SimpleRegistration extends Registration {}
242
243 // Captcha as shown here: https://xmpp.org/extensions/xep-0158.html#register
244 public static class ExtendedRegistration extends Registration {
245 private final Bitmap captcha;
246 private final Data data;
247
248 public ExtendedRegistration(final Bitmap captcha, final Data data) {
249 this.captcha = captcha;
250 this.data = data;
251 }
252
253 public Bitmap getCaptcha() {
254 return this.captcha;
255 }
256
257 public Data getData() {
258 return this.data;
259 }
260 }
261
262 // Redirection as show here: https://xmpp.org/extensions/xep-0077.html#redirect
263 public static class RedirectRegistration extends Registration {
264 private final HttpUrl url;
265
266 private RedirectRegistration(@NonNull HttpUrl url) {
267 this.url = url;
268 }
269
270 public @NonNull HttpUrl getURL() {
271 return this.url;
272 }
273
274 public static RedirectRegistration ifValid(final String url) {
275 final HttpUrl httpUrl = HttpUrl.parse(url);
276 if (httpUrl != null && httpUrl.isHttps()) {
277 return new RedirectRegistration(httpUrl);
278 }
279 throw new IllegalStateException(
280 "A URL found the registration instructions is not valid");
281 }
282 }
283
284 public static class InvalidTokenException extends IqErrorException {
285
286 public InvalidTokenException(final Iq response) {
287 super(response);
288 }
289 }
290
291 public static class RegistrationFailedException extends IqErrorException {
292
293 private final List<String> PASSWORD_TOO_WEAK_MESSAGES =
294 Arrays.asList("The password is too weak", "Please use a longer password.");
295
296 public RegistrationFailedException(final Iq response) {
297 super(response);
298 }
299
300 public Account.State asAccountState() {
301 final var error = getError();
302 final var condition = error == null ? null : error.getCondition();
303 if (condition instanceof Condition.Conflict) {
304 return Account.State.REGISTRATION_CONFLICT;
305 } else if (condition instanceof Condition.ResourceConstraint) {
306 return Account.State.REGISTRATION_PLEASE_WAIT;
307 } else if (condition instanceof Condition.NotAcceptable
308 && PASSWORD_TOO_WEAK_MESSAGES.contains(error.getTextAsString())) {
309 return Account.State.REGISTRATION_PASSWORD_TOO_WEAK;
310 } else {
311 return Account.State.REGISTRATION_FAILED;
312 }
313 }
314 }
315}