Skip to content

Commit 0322ffa

Browse files
pb-jojenkinsEdificePublic
authored andcommitted
feat: #COCO-5031, mobile phone checks (#937)
* feat: #COCO-5031, mobile phone checks * Feedbacks
1 parent 2a5d91c commit 0322ffa

7 files changed

Lines changed: 537 additions & 5 deletions

File tree

common/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@
9898
<version>4.11.0</version>
9999
<scope>test</scope>
100100
</dependency>
101+
<dependency>
102+
<groupId>com.googlecode.libphonenumber</groupId>
103+
<artifactId>libphonenumber</artifactId>
104+
<version>8.13.27</version>
105+
<scope>compile</scope>
106+
</dependency>
101107
</dependencies>
102108
<build>
103109
<plugins>

common/src/main/java/org/entcore/common/datavalidation/MobileValidation.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,28 @@
66
import io.vertx.core.json.JsonObject;
77
import org.entcore.common.datavalidation.utils.UserValidationFactory;
88
import org.entcore.common.user.UserInfos;
9+
import org.entcore.common.validation.PhoneValidation;
10+
import org.entcore.common.validation.PhoneValidation.PhoneValidationResult;
911

1012
public class MobileValidation {
1113
/**
1214
* Start a new mobile phone number validation workflow.
15+
* Validates the phone number format and type before proceeding.
1316
* @param userId user ID
1417
* @param mobile the mobile phone number to be checked
15-
* @return the new mobileState
18+
* @return the new mobileState, or a failed Future if validation fails
1619
*/
1720
static public Future<JsonObject> setPending(final EventBus unused, String userId, String mobile) {
18-
return UserValidationFactory.getInstance().setPendingMobile(userId, mobile);
21+
String region = PhoneValidation.extractRegion(mobile);
22+
23+
// Validate phone number format and type before proceeding
24+
PhoneValidationResult validation = PhoneValidation.validateMobileNumber(mobile, region);
25+
if (!validation.isValid()) {
26+
return Future.failedFuture(validation.getErrorCode());
27+
}
28+
29+
// Use normalized E.164 format for storage and SMS sending
30+
return UserValidationFactory.getInstance().setPendingMobile(userId, validation.getNormalizedNumber());
1931
}
2032

2133
/**
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/* Copyright © "Open Digital Education", 2024
2+
*
3+
* This program is published by "Open Digital Education".
4+
* You must indicate the name of the software and the company in any production /contribution
5+
* using the software and indicate on the home page of the software industry in question,
6+
* "powered by Open Digital Education" with a reference to the website: https://opendigitaleducation.com/.
7+
*
8+
* This program is free software, licensed under the terms of the GNU Affero General Public License
9+
* as published by the Free Software Foundation, version 3 of the License.
10+
*
11+
* You can redistribute this application and/or modify it since you respect the terms of the GNU Affero General Public License.
12+
* If you modify the source code and then use this modified source code in your creation, you must make available the source code of your modifications.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License along with the software.
15+
* If not, please see : <http://www.gnu.org/licenses/>. Full compliance requires reading the terms of this license and following its directives.
16+
*
17+
*/
18+
19+
package org.entcore.common.validation;
20+
21+
import org.entcore.common.utils.StringUtils;
22+
23+
import com.google.i18n.phonenumbers.NumberParseException;
24+
import com.google.i18n.phonenumbers.PhoneNumberUtil;
25+
import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat;
26+
import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberType;
27+
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
28+
29+
/**
30+
* Utility class for validating phone numbers using Google's libphonenumber.
31+
* Ensures phone numbers are mobile-compatible and not premium-rate.
32+
*/
33+
public class PhoneValidation {
34+
35+
private static final PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
36+
public static final String DEFAULT_REGION = "FR";
37+
38+
// Error codes (matching i18n keys)
39+
public static final String ERROR_EMPTY = "phone.validation.error.empty";
40+
public static final String ERROR_INVALID_FORMAT = "phone.validation.error.invalid.format";
41+
public static final String ERROR_INVALID_NUMBER = "phone.validation.error.invalid.number";
42+
public static final String ERROR_NOT_MOBILE = "phone.validation.error.not.mobile";
43+
public static final String ERROR_PREMIUM_BLOCKED = "phone.validation.error.premium.blocked";
44+
45+
/**
46+
* Result class encapsulating validation outcome.
47+
*/
48+
public static class PhoneValidationResult {
49+
private final boolean valid;
50+
private final String errorCode;
51+
private final String normalizedNumber;
52+
private final PhoneNumberType numberType;
53+
54+
private PhoneValidationResult(boolean valid, String errorCode, String normalizedNumber, PhoneNumberType numberType) {
55+
this.valid = valid;
56+
this.errorCode = errorCode;
57+
this.normalizedNumber = normalizedNumber;
58+
this.numberType = numberType;
59+
}
60+
61+
public static PhoneValidationResult success(String normalizedNumber, PhoneNumberType type) {
62+
return new PhoneValidationResult(true, null, normalizedNumber, type);
63+
}
64+
65+
public static PhoneValidationResult failure(String errorCode) {
66+
return new PhoneValidationResult(false, errorCode, null, null);
67+
}
68+
69+
public boolean isValid() {
70+
return valid;
71+
}
72+
73+
public String getErrorCode() {
74+
return errorCode;
75+
}
76+
77+
public String getNormalizedNumber() {
78+
return normalizedNumber;
79+
}
80+
81+
public PhoneNumberType getNumberType() {
82+
return numberType;
83+
}
84+
}
85+
86+
/**
87+
* Validates a phone number for mobile SMS sending with default region FR.
88+
*
89+
* @param phoneNumber The phone number to validate
90+
* @return PhoneValidationResult with validation outcome
91+
*/
92+
public static PhoneValidationResult validateMobileNumber(String phoneNumber) {
93+
return validateMobileNumber(phoneNumber, DEFAULT_REGION);
94+
}
95+
96+
/**
97+
* Validates a phone number for mobile SMS sending.
98+
*
99+
* Checks:
100+
* 1. Number is not empty
101+
* 2. Number can be parsed
102+
* 3. Number is valid according to libphonenumber
103+
* 4. Number type is MOBILE or FIXED_LINE_OR_MOBILE
104+
* 5. Number is not a premium-rate or blocked type
105+
*
106+
* @param phoneNumber The phone number to validate (with or without prefix)
107+
* @param defaultRegion ISO 3166-1 alpha-2 region code if no country prefix
108+
* @return PhoneValidationResult with validation outcome
109+
*/
110+
public static PhoneValidationResult validateMobileNumber(String phoneNumber, String defaultRegion) {
111+
// Check empty
112+
if (StringUtils.isEmpty(phoneNumber)) {
113+
return PhoneValidationResult.failure(ERROR_EMPTY);
114+
}
115+
116+
String cleanNumber = phoneNumber.trim();
117+
String region = StringUtils.isEmpty(defaultRegion) ? DEFAULT_REGION : defaultRegion;
118+
119+
PhoneNumber parsedNumber;
120+
try {
121+
parsedNumber = phoneUtil.parse(cleanNumber, region);
122+
} catch (NumberParseException e) {
123+
return PhoneValidationResult.failure(ERROR_INVALID_FORMAT);
124+
}
125+
126+
// Check if valid number
127+
if (!phoneUtil.isValidNumber(parsedNumber)) {
128+
return PhoneValidationResult.failure(ERROR_INVALID_NUMBER);
129+
}
130+
131+
// Get number type
132+
PhoneNumberType numberType = phoneUtil.getNumberType(parsedNumber);
133+
134+
// Check for premium-rate / blocked types first (more specific error)
135+
if (isPremiumRateType(numberType)) {
136+
return PhoneValidationResult.failure(ERROR_PREMIUM_BLOCKED);
137+
}
138+
139+
// Check if mobile or fixed-line-or-mobile
140+
if (!isAllowedMobileType(numberType)) {
141+
return PhoneValidationResult.failure(ERROR_NOT_MOBILE);
142+
}
143+
144+
// Valid - return normalized E.164 format
145+
String normalized = phoneUtil.format(parsedNumber, PhoneNumberFormat.E164);
146+
return PhoneValidationResult.success(normalized, numberType);
147+
}
148+
149+
/**
150+
* Checks if the number type is acceptable for mobile SMS.
151+
*
152+
* @param type The phone number type
153+
* @return true if the type is allowed for SMS
154+
*/
155+
private static boolean isAllowedMobileType(PhoneNumberType type) {
156+
return type == PhoneNumberType.MOBILE ||
157+
type == PhoneNumberType.FIXED_LINE_OR_MOBILE;
158+
}
159+
160+
/**
161+
* Checks if the number type is a blocked premium-rate type.
162+
*
163+
* @param type The phone number type
164+
* @return true if the type is a premium-rate or blocked type
165+
*/
166+
private static boolean isPremiumRateType(PhoneNumberType type) {
167+
return type == PhoneNumberType.PREMIUM_RATE ||
168+
type == PhoneNumberType.SHARED_COST ||
169+
type == PhoneNumberType.TOLL_FREE ||
170+
type == PhoneNumberType.UAN ||
171+
type == PhoneNumberType.VOICEMAIL;
172+
}
173+
174+
/**
175+
* Extracts the region code from a phone number's country calling code.
176+
* If the number starts with "+" or "00", extracts the country code and returns the corresponding region.
177+
* Otherwise returns the fallback region.
178+
*
179+
* Examples:
180+
* - "+33650335075" -> "FR"
181+
* - "0033650335075" -> "FR"
182+
* - "+34612345678" -> "ES"
183+
* - "+262692123456" -> "RE" (Réunion)
184+
* - "0650335075" -> fallbackRegion (no country code)
185+
*
186+
* @param phoneNumber The phone number (may have "+", "00" prefix, or no prefix)
187+
* @param fallbackRegion Region to use if no country code can be extracted
188+
* @return ISO 3166-1 alpha-2 region code (e.g., "FR", "ES", "BR", "RE")
189+
*/
190+
public static String extractRegion(String phoneNumber) {
191+
if (StringUtils.isEmpty(phoneNumber)) {
192+
return DEFAULT_REGION;
193+
}
194+
String cleaned = phoneNumber.trim();
195+
196+
// Convert "00" prefix to "+" for international format (e.g., 0033 -> +33)
197+
if (cleaned.startsWith("00")) {
198+
cleaned = "+" + cleaned.substring(2);
199+
}
200+
201+
// If number starts with "+", try to extract region from country code
202+
if (cleaned.startsWith("+")) {
203+
try {
204+
// Parse with a dummy region, libphonenumber will use the country code from "+"
205+
PhoneNumber parsed = phoneUtil.parse(cleaned, "ZZ");
206+
String region = phoneUtil.getRegionCodeForCountryCode(parsed.getCountryCode());
207+
if (region != null && !region.equals("ZZ")) {
208+
return region;
209+
}
210+
} catch (NumberParseException e) {
211+
// Fall through to return fallback
212+
}
213+
}
214+
215+
return DEFAULT_REGION;
216+
}
217+
218+
/**
219+
* Converts a phone number to E.164 format.
220+
* Returns null if the number cannot be parsed or is invalid.
221+
*
222+
* @param phoneNumber The phone number to convert
223+
* @param defaultRegion ISO 3166-1 alpha-2 region code if no country prefix
224+
* @return The phone number in E.164 format, or null if invalid
225+
*/
226+
public static String toE164(String phoneNumber, String defaultRegion) {
227+
if (StringUtils.isEmpty(phoneNumber)) {
228+
return null;
229+
}
230+
231+
try {
232+
String region = StringUtils.isEmpty(defaultRegion) ? DEFAULT_REGION : defaultRegion;
233+
PhoneNumber parsed = phoneUtil.parse(phoneNumber.trim(), region);
234+
235+
if (phoneUtil.isValidNumber(parsed)) {
236+
return phoneUtil.format(parsed, PhoneNumberFormat.E164);
237+
}
238+
} catch (NumberParseException e) {
239+
// Return null on parse error
240+
}
241+
242+
return null;
243+
}
244+
}

0 commit comments

Comments
 (0)