diff --git a/src/main/java/net/frozenorb/apiv3/APIv3.java b/src/main/java/net/frozenorb/apiv3/APIv3.java index 5567d12..3eb5c79 100644 --- a/src/main/java/net/frozenorb/apiv3/APIv3.java +++ b/src/main/java/net/frozenorb/apiv3/APIv3.java @@ -51,8 +51,8 @@ import net.frozenorb.apiv3.route.bannedCellCarriers.GETBannedCellCarriers; import net.frozenorb.apiv3.route.bannedCellCarriers.GETBannedCellCarriersId; import net.frozenorb.apiv3.route.bannedCellCarriers.POSTBannedCellCarriers; import net.frozenorb.apiv3.route.chatFilterList.GETChatFilter; -import net.frozenorb.apiv3.route.emailToken.GETEmailTokensIdOwner; -import net.frozenorb.apiv3.route.emailToken.POSTEmailTokensIdConfirm; +import net.frozenorb.apiv3.route.emailTokens.GETEmailTokensIdOwner; +import net.frozenorb.apiv3.route.emailTokens.POSTEmailTokensIdConfirm; import net.frozenorb.apiv3.route.grants.DELETEGrantsId; import net.frozenorb.apiv3.route.grants.GETGrants; import net.frozenorb.apiv3.route.grants.GETGrantsId; @@ -85,6 +85,7 @@ import net.frozenorb.apiv3.serialization.jackson.InstantJsonSerializer; import net.frozenorb.apiv3.serialization.jackson.UuidJsonDeserializer; import net.frozenorb.apiv3.serialization.jackson.UuidJsonSerializer; import net.frozenorb.apiv3.serialization.mongodb.UuidCodecProvider; +import net.frozenorb.apiv3.util.EmailUtils; import org.bson.Document; import org.bson.codecs.BsonValueCodecProvider; import org.bson.codecs.DocumentCodecProvider; @@ -205,6 +206,7 @@ public final class APIv3 extends AbstractVerticle { Rank.updateCache(); Server.updateCache(); ServerGroup.updateCache(); + EmailUtils.updateBannedEmailDomains(); } private ObjectMapper createMongoJacksonMapper() { @@ -324,7 +326,8 @@ public final class APIv3 extends AbstractVerticle { http.post("/users/:id/login").blockingHandler(new POSTUsersIdLogin()); http.post("/users/:id/notify").blockingHandler(new POSTUsersIdNotify(), false); http.post("/users/:id/passwordReset").blockingHandler(new POSTUsersIdPasswordReset(), false); - http.post("/users/:id/register").blockingHandler(new POSTUsersIdRegister(), false); + http.post("/users/:id/registerEmail").blockingHandler(new POSTUsersIdRegisterEmail(), false); + http.post("/users/:id/registerPhone").blockingHandler(new POSTUsersIdRegisterPhone(), false); http.post("/users/:id/setupTotp").blockingHandler(new POSTUsersIdSetupTotp(), false); http.post("/users/:id/verifyTotp").handler(new POSTUsersIdVerifyTotp()); diff --git a/src/main/java/net/frozenorb/apiv3/auditLog/AuditLogActionType.java b/src/main/java/net/frozenorb/apiv3/auditLog/AuditLogActionType.java index 0b8e1bd..7da52e0 100644 --- a/src/main/java/net/frozenorb/apiv3/auditLog/AuditLogActionType.java +++ b/src/main/java/net/frozenorb/apiv3/auditLog/AuditLogActionType.java @@ -36,8 +36,10 @@ public enum AuditLogActionType { SERVER_DELETE(false), USER_CHANGE_PASSWORD(false), USER_PASSWORD_RESET(false), - USER_REGISTER(false), + USER_REGISTER_EMAIL(false), + USER_REGISTER_PHONE(false), USER_CONFIRM_EMAIL(false), + USER_CONFIRM_PHONE(false), USER_SETUP_TOTP(false), USER_VERIFY_TOTP(false); diff --git a/src/main/java/net/frozenorb/apiv3/model/User.java b/src/main/java/net/frozenorb/apiv3/model/User.java index 024bcb8..e312755 100644 --- a/src/main/java/net/frozenorb/apiv3/model/User.java +++ b/src/main/java/net/frozenorb/apiv3/model/User.java @@ -55,7 +55,11 @@ public final class User { @Getter @ExcludeFromReplies private String pendingEmail; @Getter @ExcludeFromReplies private String pendingEmailToken; @Getter @ExcludeFromReplies private Instant pendingEmailTokenSetAt; - @Getter private String phoneNumber; + @Getter private String phone; + @Getter private Instant phoneRegisteredAt; + @Getter @ExcludeFromReplies private String pendingPhone; + @Getter @ExcludeFromReplies private String pendingPhoneToken; + @Getter @ExcludeFromReplies private Instant pendingPhoneTokenSetAt; @Getter private String lastSeenOn; @Getter private Instant lastSeenAt; @Getter private Instant firstSeenAt; @@ -78,6 +82,10 @@ public final class User { } } + public static void findByPhone(String phoneNumber, SingleResultCallback callback) { + usersCollection.find(new Document("phone", phoneNumber)).first(callback); + } + public static void findByEmail(String email, SingleResultCallback callback) { usersCollection.find(new Document("email", email)).first(callback); } @@ -460,7 +468,13 @@ public final class User { }); } - public void completeRegistration(String email) { + public void startEmailRegistration(String pendingEmail) { + this.pendingEmail = pendingEmail; + this.pendingEmailToken = UUID.randomUUID().toString().replace("-", ""); + this.pendingEmailTokenSetAt = Instant.now(); + } + + public void completeEmailRegistration(String email) { this.email = email; this.registeredAt = Instant.now(); this.pendingEmail = null; @@ -468,12 +482,20 @@ public final class User { this.pendingEmailTokenSetAt = null; } - public void startRegistration(String pendingEmail) { - this.pendingEmail = pendingEmail; + public void startPhoneRegistration(String phoneNumber) { + this.pendingPhone = phoneNumber; this.pendingEmailToken = UUID.randomUUID().toString().replace("-", ""); this.pendingEmailTokenSetAt = Instant.now(); } + public void completeRegistration(String email) { + this.email = email; + this.registeredAt = Instant.now(); + this.pendingEmail = null; + this.pendingEmailToken = null; + this.pendingEmailTokenSetAt = null; + } + public void hasPermissionAnywhere(String permission, SingleResultCallback callback) { getCompoundedPermissions((permissions, error) -> { if (error != null) { diff --git a/src/main/java/net/frozenorb/apiv3/route/emailToken/GETEmailTokensIdOwner.java b/src/main/java/net/frozenorb/apiv3/route/emailTokens/GETEmailTokensIdOwner.java similarity index 92% rename from src/main/java/net/frozenorb/apiv3/route/emailToken/GETEmailTokensIdOwner.java rename to src/main/java/net/frozenorb/apiv3/route/emailTokens/GETEmailTokensIdOwner.java index c5a98ab..f10b9ce 100644 --- a/src/main/java/net/frozenorb/apiv3/route/emailToken/GETEmailTokensIdOwner.java +++ b/src/main/java/net/frozenorb/apiv3/route/emailTokens/GETEmailTokensIdOwner.java @@ -1,4 +1,4 @@ -package net.frozenorb.apiv3.route.emailToken; +package net.frozenorb.apiv3.route.emailTokens; import io.vertx.core.Handler; import io.vertx.ext.web.RoutingContext; diff --git a/src/main/java/net/frozenorb/apiv3/route/emailToken/POSTEmailTokensIdConfirm.java b/src/main/java/net/frozenorb/apiv3/route/emailTokens/POSTEmailTokensIdConfirm.java similarity index 95% rename from src/main/java/net/frozenorb/apiv3/route/emailToken/POSTEmailTokensIdConfirm.java rename to src/main/java/net/frozenorb/apiv3/route/emailTokens/POSTEmailTokensIdConfirm.java index 8bb525e..8d998c3 100644 --- a/src/main/java/net/frozenorb/apiv3/route/emailToken/POSTEmailTokensIdConfirm.java +++ b/src/main/java/net/frozenorb/apiv3/route/emailTokens/POSTEmailTokensIdConfirm.java @@ -1,4 +1,4 @@ -package net.frozenorb.apiv3.route.emailToken; +package net.frozenorb.apiv3.route.emailTokens; import com.google.common.collect.ImmutableMap; import com.mongodb.client.result.UpdateResult; @@ -46,7 +46,7 @@ public final class POSTEmailTokensIdConfirm implements Handler { return; } - user.completeRegistration(user.getPendingEmail()); + user.completeEmailRegistration(user.getPendingEmail()); user.updatePassword(password); BlockingCallback callback = new BlockingCallback<>(); user.save(callback); diff --git a/src/main/java/net/frozenorb/apiv3/route/users/POSTUsersIdRegister.java b/src/main/java/net/frozenorb/apiv3/route/users/POSTUsersIdRegisterEmail.java similarity index 94% rename from src/main/java/net/frozenorb/apiv3/route/users/POSTUsersIdRegister.java rename to src/main/java/net/frozenorb/apiv3/route/users/POSTUsersIdRegisterEmail.java index 54aff97..1aea9e0 100644 --- a/src/main/java/net/frozenorb/apiv3/route/users/POSTUsersIdRegister.java +++ b/src/main/java/net/frozenorb/apiv3/route/users/POSTUsersIdRegisterEmail.java @@ -18,7 +18,7 @@ import net.frozenorb.apiv3.util.ErrorUtils; import java.util.Map; import java.util.concurrent.TimeUnit; -public final class POSTUsersIdRegister implements Handler { +public final class POSTUsersIdRegisterEmail implements Handler { public void handle(RoutingContext ctx) { BlockingCallback userCallback = new BlockingCallback<>(); @@ -61,7 +61,7 @@ public final class POSTUsersIdRegister implements Handler { return; } - user.startRegistration(email); + user.startEmailRegistration(email); BlockingCallback callback = new BlockingCallback<>(); user.save(callback); callback.get(); @@ -80,7 +80,7 @@ public final class POSTUsersIdRegister implements Handler { if (error != null) { ErrorUtils.respondInternalError(ctx, error); } else { - AuditLog.log(user.getId(), requestBody.getString("userIp"), ctx, AuditLogActionType.USER_REGISTER, (ignored2, error2) -> { + AuditLog.log(user.getId(), requestBody.getString("userIp"), ctx, AuditLogActionType.USER_REGISTER_EMAIL, (ignored2, error2) -> { if (error2 != null) { ErrorUtils.respondInternalError(ctx, error2); } else { diff --git a/src/main/java/net/frozenorb/apiv3/route/users/POSTUsersIdRegisterPhone.java b/src/main/java/net/frozenorb/apiv3/route/users/POSTUsersIdRegisterPhone.java new file mode 100644 index 0000000..6185fee --- /dev/null +++ b/src/main/java/net/frozenorb/apiv3/route/users/POSTUsersIdRegisterPhone.java @@ -0,0 +1,91 @@ +package net.frozenorb.apiv3.route.users; + +import com.google.common.collect.ImmutableMap; +import com.mongodb.client.result.UpdateResult; +import io.vertx.core.Handler; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; +import net.frozenorb.apiv3.APIv3; +import net.frozenorb.apiv3.auditLog.AuditLog; +import net.frozenorb.apiv3.auditLog.AuditLogActionType; +import net.frozenorb.apiv3.model.NotificationTemplate; +import net.frozenorb.apiv3.model.User; +import net.frozenorb.apiv3.unsorted.BlockingCallback; +import net.frozenorb.apiv3.unsorted.Notification; +import net.frozenorb.apiv3.util.ErrorUtils; +import net.frozenorb.apiv3.util.PhoneUtils; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public final class POSTUsersIdRegisterPhone implements Handler { + + public void handle(RoutingContext ctx) { + BlockingCallback userCallback = new BlockingCallback<>(); + User.findById(ctx.request().getParam("id"), userCallback); + User user = userCallback.get(); + + if (user == null) { + ErrorUtils.respondNotFound(ctx, "User", ctx.request().getParam("id")); + return; + } + + if (user.getPhone() != null) { + ErrorUtils.respondInvalidInput(ctx, "User provided already has phone set."); + return; + } + + JsonObject requestBody = ctx.getBodyAsJson(); + String phone = requestBody.getString("phone"); + + if (!PhoneUtils.isValidPhone(phone)) { + ErrorUtils.respondInvalidInput(ctx, phone + " is not a valid phone number."); + return; + } + + if (user.getPendingPhoneToken() != null && (System.currentTimeMillis() - user.getPendingPhoneTokenSetAt().toEpochMilli()) < TimeUnit.DAYS.toMillis(2)) { + ErrorUtils.respondGeneric(ctx, 200, "We just recently sent you a confirmation code. Please wait before trying to register again."); + return; + } + + BlockingCallback samePhoneCallback = new BlockingCallback<>(); + User.findByPhone(phone, samePhoneCallback); + + if (samePhoneCallback.get() != null) { + ErrorUtils.respondInvalidInput(ctx, phone + " is already in use."); + return; + } + + user.startPhoneRegistration(phone); + BlockingCallback callback = new BlockingCallback<>(); + user.save(callback); + callback.get(); + + Map replacements = ImmutableMap.of( + "username", user.getLastUsername(), + "phone", user.getPendingPhone(), + "phoneToken", user.getPendingPhoneToken() + ); + + BlockingCallback notificationTemplateCallback = new BlockingCallback<>(); + NotificationTemplate.findById("phone-confirmation", notificationTemplateCallback); + Notification notification = new Notification(notificationTemplateCallback.get(), replacements, replacements); + + notification.sendAsText(user.getPendingPhone(), (ignored, error) -> { + if (error != null) { + ErrorUtils.respondInternalError(ctx, error); + } else { + AuditLog.log(user.getId(), requestBody.getString("userIp"), ctx, AuditLogActionType.USER_REGISTER_PHONE, (ignored2, error2) -> { + if (error2 != null) { + ErrorUtils.respondInternalError(ctx, error2); + } else { + APIv3.respondJson(ctx, ImmutableMap.of( + "success", true + )); + } + }); + } + }); + } + +} \ No newline at end of file diff --git a/src/main/java/net/frozenorb/apiv3/util/EmailUtils.java b/src/main/java/net/frozenorb/apiv3/util/EmailUtils.java index 0792aa7..665dbd3 100644 --- a/src/main/java/net/frozenorb/apiv3/util/EmailUtils.java +++ b/src/main/java/net/frozenorb/apiv3/util/EmailUtils.java @@ -11,7 +11,6 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; -@Slf4j @UtilityClass public class EmailUtils { @@ -23,11 +22,10 @@ public class EmailUtils { private static Set bannedEmailDomains = ImmutableSet.of(); static { - updateBannedEmailDomains(); APIv3.getVertxInstance().setPeriodic(TimeUnit.MINUTES.toMillis(10), (id) -> updateBannedEmailDomains()); } - private static void updateBannedEmailDomains() { + public static void updateBannedEmailDomains() { httpsClient.get(443, "raw.githubusercontent.com", "/martenson/disposable-email-domains/master/disposable_email_blacklist.conf", (response) -> { response.bodyHandler((body) -> bannedEmailDomains = ImmutableSet.copyOf(body.toString().split("\n"))); response.exceptionHandler(Throwable::printStackTrace); diff --git a/src/main/java/net/frozenorb/apiv3/util/PhoneUtils.java b/src/main/java/net/frozenorb/apiv3/util/PhoneUtils.java new file mode 100644 index 0000000..12cb0c5 --- /dev/null +++ b/src/main/java/net/frozenorb/apiv3/util/PhoneUtils.java @@ -0,0 +1,20 @@ +package net.frozenorb.apiv3.util; + +import lombok.experimental.UtilityClass; + +import java.util.regex.Pattern; + +@UtilityClass +public class PhoneUtils { + + private static final Pattern VALID_PHONE_PATTERN = Pattern.compile( + "^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", + Pattern.CASE_INSENSITIVE + ); + + public static boolean isValidPhone(String phoneNumber) { + return true; // TODO + //return VALID_EMAIL_PATTERN.matcher(email).matches(); + } + +} \ No newline at end of file