registrationExecutorFactory;
@Inject
@@ -47,9 +49,9 @@ public class AsyncRegister implements AsynchronousProcess {
/**
* Performs the registration process for the given player.
*
- * @param variant the registration method
+ * @param variant the registration method
* @param parameters the parameters
- * @param parameters type
+ * @param
parameters type
*/
public
void register(RegistrationMethod
variant, P parameters) {
if (preRegisterCheck(parameters.getPlayer())) {
@@ -60,6 +62,13 @@ public class AsyncRegister implements AsynchronousProcess {
}
}
+ /**
+ * Checks if the player is able to register, in that case the {@link AuthMeAsyncPreRegisterEvent} is invoked.
+ *
+ * @param player the player which is trying to register.
+ *
+ * @return true if the checks are successful and the event hasn't marked the action as denied, false otherwise.
+ */
private boolean preRegisterCheck(Player player) {
final String name = player.getName().toLowerCase();
if (playerCache.isAuthenticated(name)) {
@@ -73,6 +82,12 @@ public class AsyncRegister implements AsynchronousProcess {
return false;
}
+ AuthMeAsyncPreRegisterEvent event = bukkitService.createAndCallEvent(
+ isAsync -> new AuthMeAsyncPreRegisterEvent(player, isAsync));
+ if (!event.canRegister()) {
+ return false;
+ }
+
return isPlayerIpAllowedToRegister(player);
}
@@ -80,11 +95,11 @@ public class AsyncRegister implements AsynchronousProcess {
* Executes the registration.
*
* @param parameters the registration parameters
- * @param executor the executor to perform the registration process with
- * @param
registration params type
+ * @param executor the executor to perform the registration process with
+ * @param
registration params type
*/
private
- void executeRegistration(P parameters, RegistrationExecutor
executor) {
+ void executeRegistration(P parameters, RegistrationExecutor
executor) {
PlayerAuth auth = executor.buildPlayerAuth(parameters);
if (database.saveAuth(auth)) {
executor.executePostPersistAction(parameters);
@@ -98,15 +113,15 @@ public class AsyncRegister implements AsynchronousProcess {
* Checks whether the registration threshold has been exceeded for the given player's IP address.
*
* @param player the player to check
+ *
* @return true if registration may take place, false otherwise (IP check failed)
*/
private boolean isPlayerIpAllowedToRegister(Player player) {
final int maxRegPerIp = service.getProperty(RestrictionSettings.MAX_REGISTRATION_PER_IP);
final String ip = PlayerUtils.getPlayerIp(player);
if (maxRegPerIp > 0
- && !"127.0.0.1".equalsIgnoreCase(ip)
- && !"localhost".equalsIgnoreCase(ip)
- && !permissionsManager.hasPermission(player, ALLOW_MULTIPLE_ACCOUNTS)) {
+ && !InternetProtocolUtils.isLoopbackAddress(ip)
+ && !service.hasPermission(player, ALLOW_MULTIPLE_ACCOUNTS)) {
List otherAccounts = database.getAllAuthsByIp(ip);
if (otherAccounts.size() >= maxRegPerIp) {
service.send(player, MessageKey.MAX_REGISTER_EXCEEDED, Integer.toString(maxRegPerIp),
diff --git a/src/main/java/fr/xephi/authme/process/register/ProcessSyncEmailRegister.java b/src/main/java/fr/xephi/authme/process/register/ProcessSyncEmailRegister.java
index b43a85639..e740a0ded 100644
--- a/src/main/java/fr/xephi/authme/process/register/ProcessSyncEmailRegister.java
+++ b/src/main/java/fr/xephi/authme/process/register/ProcessSyncEmailRegister.java
@@ -2,17 +2,24 @@ package fr.xephi.authme.process.register;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.data.limbo.LimboService;
+import fr.xephi.authme.events.RegisterEvent;
import fr.xephi.authme.message.MessageKey;
import fr.xephi.authme.process.SynchronousProcess;
+import fr.xephi.authme.service.BukkitService;
import fr.xephi.authme.service.CommonService;
import fr.xephi.authme.util.PlayerUtils;
import org.bukkit.entity.Player;
import javax.inject.Inject;
-
+/**
+ * Performs synchronous tasks after a successful {@link RegistrationType#EMAIL email registration}.
+ */
public class ProcessSyncEmailRegister implements SynchronousProcess {
+ @Inject
+ private BukkitService bukkitService;
+
@Inject
private CommonService service;
@@ -22,11 +29,17 @@ public class ProcessSyncEmailRegister implements SynchronousProcess {
ProcessSyncEmailRegister() {
}
+ /**
+ * Performs sync tasks for a player which has just registered by email.
+ *
+ * @param player the recently registered player
+ */
public void processEmailRegister(Player player) {
service.send(player, MessageKey.ACCOUNT_NOT_ACTIVATED);
limboService.replaceTasksAfterRegistration(player);
player.saveData();
+ bukkitService.callEvent(new RegisterEvent(player));
ConsoleLogger.fine(player.getName() + " registered " + PlayerUtils.getPlayerIp(player));
}
diff --git a/src/main/java/fr/xephi/authme/process/register/ProcessSyncPasswordRegister.java b/src/main/java/fr/xephi/authme/process/register/ProcessSyncPasswordRegister.java
index 589c88aa2..dc8aa136f 100644
--- a/src/main/java/fr/xephi/authme/process/register/ProcessSyncPasswordRegister.java
+++ b/src/main/java/fr/xephi/authme/process/register/ProcessSyncPasswordRegister.java
@@ -2,8 +2,10 @@ package fr.xephi.authme.process.register;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.data.limbo.LimboService;
+import fr.xephi.authme.events.RegisterEvent;
import fr.xephi.authme.message.MessageKey;
import fr.xephi.authme.process.SynchronousProcess;
+import fr.xephi.authme.service.BukkitService;
import fr.xephi.authme.service.CommonService;
import fr.xephi.authme.service.bungeecord.BungeeSender;
import fr.xephi.authme.settings.commandconfig.CommandManager;
@@ -15,6 +17,7 @@ import org.bukkit.entity.Player;
import javax.inject.Inject;
/**
+ * Performs synchronous tasks after a successful {@link RegistrationType#PASSWORD password registration}.
*/
public class ProcessSyncPasswordRegister implements SynchronousProcess {
@@ -30,6 +33,9 @@ public class ProcessSyncPasswordRegister implements SynchronousProcess {
@Inject
private CommandManager commandManager;
+ @Inject
+ private BukkitService bukkitService;
+
ProcessSyncPasswordRegister() {
}
@@ -46,6 +52,11 @@ public class ProcessSyncPasswordRegister implements SynchronousProcess {
}
}
+ /**
+ * Processes a player having registered with a password.
+ *
+ * @param player the newly registered player
+ */
public void processPasswordRegister(Player player) {
service.send(player, MessageKey.REGISTER_SUCCESS);
@@ -54,11 +65,12 @@ public class ProcessSyncPasswordRegister implements SynchronousProcess {
}
player.saveData();
+ bukkitService.callEvent(new RegisterEvent(player));
ConsoleLogger.fine(player.getName() + " registered " + PlayerUtils.getPlayerIp(player));
// Kick Player after Registration is enabled, kick the player
if (service.getProperty(RegistrationSettings.FORCE_KICK_AFTER_REGISTER)) {
- player.kickPlayer(service.retrieveSingleMessage(MessageKey.REGISTER_SUCCESS));
+ player.kickPlayer(service.retrieveSingleMessage(player, MessageKey.REGISTER_SUCCESS));
return;
}
diff --git a/src/main/java/fr/xephi/authme/process/register/executors/EmailRegisterExecutor.java b/src/main/java/fr/xephi/authme/process/register/executors/EmailRegisterExecutor.java
index 0f4153b18..c344447c8 100644
--- a/src/main/java/fr/xephi/authme/process/register/executors/EmailRegisterExecutor.java
+++ b/src/main/java/fr/xephi/authme/process/register/executors/EmailRegisterExecutor.java
@@ -4,7 +4,6 @@ import fr.xephi.authme.data.auth.PlayerAuth;
import fr.xephi.authme.datasource.DataSource;
import fr.xephi.authme.mail.EmailService;
import fr.xephi.authme.message.MessageKey;
-import fr.xephi.authme.permission.PermissionsManager;
import fr.xephi.authme.process.SyncProcessManager;
import fr.xephi.authme.security.PasswordSecurity;
import fr.xephi.authme.security.crypts.HashedPassword;
@@ -25,9 +24,6 @@ import static fr.xephi.authme.settings.properties.EmailSettings.RECOVERY_PASSWOR
*/
class EmailRegisterExecutor implements RegistrationExecutor {
- @Inject
- private PermissionsManager permissionsManager;
-
@Inject
private DataSource dataSource;
@@ -46,7 +42,7 @@ class EmailRegisterExecutor implements RegistrationExecutor
@Override
public boolean isRegistrationAdmitted(EmailRegisterParams params) {
final int maxRegPerEmail = commonService.getProperty(EmailSettings.MAX_REG_PER_EMAIL);
- if (maxRegPerEmail > 0 && !permissionsManager.hasPermission(params.getPlayer(), ALLOW_MULTIPLE_ACCOUNTS)) {
+ if (maxRegPerEmail > 0 && !commonService.hasPermission(params.getPlayer(), ALLOW_MULTIPLE_ACCOUNTS)) {
int otherAccounts = dataSource.countAuthsByEmail(params.getEmail());
if (otherAccounts >= maxRegPerEmail) {
commonService.send(params.getPlayer(), MessageKey.MAX_REGISTER_EXCEEDED,
diff --git a/src/main/java/fr/xephi/authme/security/HashUtils.java b/src/main/java/fr/xephi/authme/security/HashUtils.java
index 3578c80f3..642081c6d 100644
--- a/src/main/java/fr/xephi/authme/security/HashUtils.java
+++ b/src/main/java/fr/xephi/authme/security/HashUtils.java
@@ -1,6 +1,7 @@
package fr.xephi.authme.security;
import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -78,6 +79,20 @@ public final class HashUtils {
return hash.length() > 3 && hash.substring(0, 2).equals("$2");
}
+ /**
+ * Checks whether the two strings are equal to each other in a time-constant manner.
+ * This helps to avoid timing side channel attacks,
+ * cf. issue #1561.
+ *
+ * @param string1 first string
+ * @param string2 second string
+ * @return true if the strings are equal to each other, false otherwise
+ */
+ public static boolean isEqual(String string1, String string2) {
+ return MessageDigest.isEqual(
+ string1.getBytes(StandardCharsets.UTF_8), string2.getBytes(StandardCharsets.UTF_8));
+ }
+
/**
* Hash the message with the given algorithm and return the hash in its hexadecimal notation.
*
diff --git a/src/main/java/fr/xephi/authme/security/crypts/BCrypt.java b/src/main/java/fr/xephi/authme/security/crypts/BCrypt.java
index 02e12d459..8b454c799 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/BCrypt.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/BCrypt.java
@@ -8,7 +8,7 @@ import fr.xephi.authme.security.crypts.description.SaltType;
import fr.xephi.authme.security.crypts.description.Usage;
import fr.xephi.authme.settings.Settings;
import fr.xephi.authme.settings.properties.HooksSettings;
-import fr.xephi.authme.util.StringUtils;
+import fr.xephi.authme.util.ExceptionUtils;
import javax.inject.Inject;
@@ -39,7 +39,7 @@ public class BCrypt implements EncryptionMethod {
try {
return HashUtils.isValidBcryptHash(hash.getHash()) && BCryptService.checkpw(password, hash.getHash());
} catch (IllegalArgumentException e) {
- ConsoleLogger.warning("Bcrypt checkpw() returned " + StringUtils.formatException(e));
+ ConsoleLogger.warning("Bcrypt checkpw() returned " + ExceptionUtils.formatException(e));
}
return false;
}
diff --git a/src/main/java/fr/xephi/authme/security/crypts/BCrypt2y.java b/src/main/java/fr/xephi/authme/security/crypts/BCrypt2y.java
index cf4807abc..a22a68906 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/BCrypt2y.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/BCrypt2y.java
@@ -3,6 +3,8 @@ package fr.xephi.authme.security.crypts;
import fr.xephi.authme.security.crypts.description.Recommendation;
import fr.xephi.authme.security.crypts.description.Usage;
+import static fr.xephi.authme.security.HashUtils.isEqual;
+
@Recommendation(Usage.RECOMMENDED)
public class BCrypt2y extends HexSaltedMethod {
@@ -23,7 +25,7 @@ public class BCrypt2y extends HexSaltedMethod {
// The salt is the first 29 characters of the hash
String salt = hash.substring(0, 29);
- return hash.equals(computeHash(password, salt, null));
+ return isEqual(hash, computeHash(password, salt, null));
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/Ipb4.java b/src/main/java/fr/xephi/authme/security/crypts/Ipb4.java
index 762897955..c7bfcd65b 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/Ipb4.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/Ipb4.java
@@ -2,12 +2,12 @@ package fr.xephi.authme.security.crypts;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.security.HashUtils;
-import fr.xephi.authme.util.RandomStringUtils;
import fr.xephi.authme.security.crypts.description.HasSalt;
import fr.xephi.authme.security.crypts.description.Recommendation;
import fr.xephi.authme.security.crypts.description.SaltType;
import fr.xephi.authme.security.crypts.description.Usage;
-import fr.xephi.authme.util.StringUtils;
+import fr.xephi.authme.util.ExceptionUtils;
+import fr.xephi.authme.util.RandomStringUtils;
/**
@@ -37,7 +37,7 @@ public class Ipb4 implements EncryptionMethod {
try {
return HashUtils.isValidBcryptHash(hash.getHash()) && BCryptService.checkpw(password, hash.getHash());
} catch (IllegalArgumentException e) {
- ConsoleLogger.warning("Bcrypt checkpw() returned " + StringUtils.formatException(e));
+ ConsoleLogger.warning("Bcrypt checkpw() returned " + ExceptionUtils.formatException(e));
}
return false;
}
diff --git a/src/main/java/fr/xephi/authme/security/crypts/Joomla.java b/src/main/java/fr/xephi/authme/security/crypts/Joomla.java
index 462f5cb28..2ecc1d8d3 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/Joomla.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/Joomla.java
@@ -4,6 +4,8 @@ import fr.xephi.authme.security.HashUtils;
import fr.xephi.authme.security.crypts.description.Recommendation;
import fr.xephi.authme.security.crypts.description.Usage;
+import static fr.xephi.authme.security.HashUtils.isEqual;
+
@Recommendation(Usage.ACCEPTABLE)
public class Joomla extends HexSaltedMethod {
@@ -16,7 +18,7 @@ public class Joomla extends HexSaltedMethod {
public boolean comparePassword(String password, HashedPassword hashedPassword, String unusedName) {
String hash = hashedPassword.getHash();
String[] hashParts = hash.split(":");
- return hashParts.length == 2 && hash.equals(computeHash(password, hashParts[1], null));
+ return hashParts.length == 2 && isEqual(hash, computeHash(password, hashParts[1], null));
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/Md5vB.java b/src/main/java/fr/xephi/authme/security/crypts/Md5vB.java
index c244ec49d..00656964f 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/Md5vB.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/Md5vB.java
@@ -1,5 +1,6 @@
package fr.xephi.authme.security.crypts;
+import static fr.xephi.authme.security.HashUtils.isEqual;
import static fr.xephi.authme.security.HashUtils.md5;
public class Md5vB extends HexSaltedMethod {
@@ -13,7 +14,7 @@ public class Md5vB extends HexSaltedMethod {
public boolean comparePassword(String password, HashedPassword hashedPassword, String name) {
String hash = hashedPassword.getHash();
String[] line = hash.split("\\$");
- return line.length == 4 && hash.equals(computeHash(password, line[2], name));
+ return line.length == 4 && isEqual(hash, computeHash(password, line[2], name));
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/Pbkdf2.java b/src/main/java/fr/xephi/authme/security/crypts/Pbkdf2.java
index 5367a2a12..d9695abc5 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/Pbkdf2.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/Pbkdf2.java
@@ -1,5 +1,6 @@
package fr.xephi.authme.security.crypts;
+import com.google.common.primitives.Ints;
import de.rtner.misc.BinTools;
import de.rtner.security.auth.spi.PBKDF2Engine;
import de.rtner.security.auth.spi.PBKDF2Parameters;
@@ -38,13 +39,12 @@ public class Pbkdf2 extends HexSaltedMethod {
if (line.length != 4) {
return false;
}
- int iterations;
- try {
- iterations = Integer.parseInt(line[1]);
- } catch (NumberFormatException e) {
- ConsoleLogger.logException("Cannot read number of rounds for Pbkdf2", e);
+ Integer iterations = Ints.tryParse(line[1]);
+ if (iterations == null) {
+ ConsoleLogger.warning("Cannot read number of rounds for Pbkdf2: '" + line[1] + "'");
return false;
}
+
String salt = line[2];
byte[] derivedKey = BinTools.hex2bin(line[3]);
PBKDF2Parameters params = new PBKDF2Parameters("HmacSHA256", "UTF-8", salt.getBytes(), iterations, derivedKey);
diff --git a/src/main/java/fr/xephi/authme/security/crypts/Pbkdf2Django.java b/src/main/java/fr/xephi/authme/security/crypts/Pbkdf2Django.java
index f5a0abb63..e32930db1 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/Pbkdf2Django.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/Pbkdf2Django.java
@@ -1,5 +1,6 @@
package fr.xephi.authme.security.crypts;
+import com.google.common.primitives.Ints;
import de.rtner.security.auth.spi.PBKDF2Engine;
import de.rtner.security.auth.spi.PBKDF2Parameters;
import fr.xephi.authme.ConsoleLogger;
@@ -27,13 +28,12 @@ public class Pbkdf2Django extends HexSaltedMethod {
if (line.length != 4) {
return false;
}
- int iterations;
- try {
- iterations = Integer.parseInt(line[1]);
- } catch (NumberFormatException e) {
- ConsoleLogger.logException("Could not read number of rounds for Pbkdf2Django:", e);
+ Integer iterations = Ints.tryParse(line[1]);
+ if (iterations == null) {
+ ConsoleLogger.warning("Cannot read number of rounds for Pbkdf2Django: '" + line[1] + "'");
return false;
}
+
String salt = line[2];
byte[] derivedKey = Base64.getDecoder().decode(line[3]);
PBKDF2Parameters params = new PBKDF2Parameters("HmacSHA256", "ASCII", salt.getBytes(), iterations, derivedKey);
diff --git a/src/main/java/fr/xephi/authme/security/crypts/PhpBB.java b/src/main/java/fr/xephi/authme/security/crypts/PhpBB.java
index 70ac322d0..2d641706c 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/PhpBB.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/PhpBB.java
@@ -10,6 +10,8 @@ import fr.xephi.authme.security.crypts.description.Usage;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
+import static fr.xephi.authme.security.HashUtils.isEqual;
+
/**
* Encryption method compatible with phpBB3.
*
@@ -43,7 +45,7 @@ public class PhpBB implements EncryptionMethod {
} else if (hash.length() == 34) {
return PhpassSaltedMd5.phpbb_check_hash(password, hash);
} else {
- return PhpassSaltedMd5.md5(password).equals(hash);
+ return isEqual(hash, PhpassSaltedMd5.md5(password));
}
}
@@ -153,7 +155,7 @@ public class PhpBB implements EncryptionMethod {
}
private static boolean phpbb_check_hash(String password, String hash) {
- return _hash_crypt_private(password, hash).equals(hash);
+ return isEqual(hash, _hash_crypt_private(password, hash)); // #1561: fix timing issue
}
}
}
diff --git a/src/main/java/fr/xephi/authme/security/crypts/SeparateSaltMethod.java b/src/main/java/fr/xephi/authme/security/crypts/SeparateSaltMethod.java
index d0dacda4d..c0ec13dd7 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/SeparateSaltMethod.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/SeparateSaltMethod.java
@@ -1,5 +1,7 @@
package fr.xephi.authme.security.crypts;
+import static fr.xephi.authme.security.HashUtils.isEqual;
+
/**
* Common supertype for encryption methods which store their salt separately from the hash.
*/
@@ -19,7 +21,7 @@ public abstract class SeparateSaltMethod implements EncryptionMethod {
@Override
public boolean comparePassword(String password, HashedPassword hashedPassword, String name) {
- return hashedPassword.getHash().equals(computeHash(password, hashedPassword.getSalt(), null));
+ return isEqual(hashedPassword.getHash(), computeHash(password, hashedPassword.getSalt(), null));
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/Sha256.java b/src/main/java/fr/xephi/authme/security/crypts/Sha256.java
index 1b77a2e44..ce6b25492 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/Sha256.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/Sha256.java
@@ -3,6 +3,7 @@ package fr.xephi.authme.security.crypts;
import fr.xephi.authme.security.crypts.description.Recommendation;
import fr.xephi.authme.security.crypts.description.Usage;
+import static fr.xephi.authme.security.HashUtils.isEqual;
import static fr.xephi.authme.security.HashUtils.sha256;
@Recommendation(Usage.RECOMMENDED)
@@ -14,10 +15,10 @@ public class Sha256 extends HexSaltedMethod {
}
@Override
- public boolean comparePassword(String password, HashedPassword hashedPassword, String playerName) {
+ public boolean comparePassword(String password, HashedPassword hashedPassword, String name) {
String hash = hashedPassword.getHash();
String[] line = hash.split("\\$");
- return line.length == 4 && hash.equals(computeHash(password, line[2], ""));
+ return line.length == 4 && isEqual(hash, computeHash(password, line[2], name));
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/Smf.java b/src/main/java/fr/xephi/authme/security/crypts/Smf.java
index 24d28fe6c..e24c1b83d 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/Smf.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/Smf.java
@@ -7,6 +7,8 @@ import fr.xephi.authme.security.crypts.description.SaltType;
import fr.xephi.authme.security.crypts.description.Usage;
import fr.xephi.authme.util.RandomStringUtils;
+import static fr.xephi.authme.security.HashUtils.isEqual;
+
/**
* Hashing algorithm for SMF forums.
*
@@ -32,7 +34,7 @@ public class Smf implements EncryptionMethod {
@Override
public boolean comparePassword(String password, HashedPassword hashedPassword, String name) {
- return computeHash(password, null, name).equals(hashedPassword.getHash());
+ return isEqual(hashedPassword.getHash(), computeHash(password, null, name));
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/UnsaltedMethod.java b/src/main/java/fr/xephi/authme/security/crypts/UnsaltedMethod.java
index a8f2040e5..33815ec77 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/UnsaltedMethod.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/UnsaltedMethod.java
@@ -5,6 +5,8 @@ import fr.xephi.authme.security.crypts.description.Recommendation;
import fr.xephi.authme.security.crypts.description.SaltType;
import fr.xephi.authme.security.crypts.description.Usage;
+import static fr.xephi.authme.security.HashUtils.isEqual;
+
/**
* Common type for encryption methods which do not use any salt whatsoever.
*/
@@ -26,7 +28,7 @@ public abstract class UnsaltedMethod implements EncryptionMethod {
@Override
public boolean comparePassword(String password, HashedPassword hashedPassword, String name) {
- return hashedPassword.getHash().equals(computeHash(password));
+ return isEqual(hashedPassword.getHash(), computeHash(password));
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/UsernameSaltMethod.java b/src/main/java/fr/xephi/authme/security/crypts/UsernameSaltMethod.java
index 23101e22a..f5930fcf5 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/UsernameSaltMethod.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/UsernameSaltMethod.java
@@ -5,6 +5,8 @@ import fr.xephi.authme.security.crypts.description.Recommendation;
import fr.xephi.authme.security.crypts.description.SaltType;
import fr.xephi.authme.security.crypts.description.Usage;
+import static fr.xephi.authme.security.HashUtils.isEqual;
+
/**
* Common supertype of encryption methods that use a player's username
* (or something based on it) as embedded salt.
@@ -23,7 +25,7 @@ public abstract class UsernameSaltMethod implements EncryptionMethod {
@Override
public boolean comparePassword(String password, HashedPassword hashedPassword, String name) {
- return hashedPassword.getHash().equals(computeHash(password, name).getHash());
+ return isEqual(hashedPassword.getHash(), computeHash(password, name).getHash());
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/Wbb4.java b/src/main/java/fr/xephi/authme/security/crypts/Wbb4.java
index d1d4953d1..f396c5d84 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/Wbb4.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/Wbb4.java
@@ -3,6 +3,7 @@ package fr.xephi.authme.security.crypts;
import fr.xephi.authme.security.crypts.description.Recommendation;
import fr.xephi.authme.security.crypts.description.Usage;
+import static fr.xephi.authme.security.HashUtils.isEqual;
import static fr.xephi.authme.security.crypts.BCryptService.hashpw;
@Recommendation(Usage.RECOMMENDED)
@@ -14,12 +15,12 @@ public class Wbb4 extends HexSaltedMethod {
}
@Override
- public boolean comparePassword(String password, HashedPassword hashedPassword, String playerName) {
+ public boolean comparePassword(String password, HashedPassword hashedPassword, String name) {
if (hashedPassword.getHash().length() != 60) {
return false;
}
String salt = hashedPassword.getHash().substring(0, 29);
- return computeHash(password, salt, null).equals(hashedPassword.getHash());
+ return isEqual(hashedPassword.getHash(), computeHash(password, salt, name));
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/Wordpress.java b/src/main/java/fr/xephi/authme/security/crypts/Wordpress.java
index 768b92c5d..f70c09496 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/Wordpress.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/Wordpress.java
@@ -12,6 +12,8 @@ import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
+import static fr.xephi.authme.security.HashUtils.isEqual;
+
@Recommendation(Usage.ACCEPTABLE)
@HasSalt(value = SaltType.TEXT, length = 9)
// Note ljacqu 20151228: Wordpress is actually a salted algorithm but salt generation is handled internally
@@ -115,7 +117,7 @@ public class Wordpress extends UnsaltedMethod {
public boolean comparePassword(String password, HashedPassword hashedPassword, String name) {
String hash = hashedPassword.getHash();
String comparedHash = crypt(password, hash);
- return comparedHash.equals(hash);
+ return isEqual(hash, comparedHash);
}
}
diff --git a/src/main/java/fr/xephi/authme/security/crypts/XAuth.java b/src/main/java/fr/xephi/authme/security/crypts/XAuth.java
index 9f921b6ae..62f2e0d71 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/XAuth.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/XAuth.java
@@ -3,6 +3,8 @@ package fr.xephi.authme.security.crypts;
import fr.xephi.authme.security.crypts.description.Recommendation;
import fr.xephi.authme.security.crypts.description.Usage;
+import static fr.xephi.authme.security.HashUtils.isEqual;
+
@Recommendation(Usage.RECOMMENDED)
public class XAuth extends HexSaltedMethod {
@@ -23,14 +25,14 @@ public class XAuth extends HexSaltedMethod {
}
@Override
- public boolean comparePassword(String password, HashedPassword hashedPassword, String playerName) {
+ public boolean comparePassword(String password, HashedPassword hashedPassword, String name) {
String hash = hashedPassword.getHash();
int saltPos = password.length() >= hash.length() ? hash.length() - 1 : password.length();
if (saltPos + 12 > hash.length()) {
return false;
}
String salt = hash.substring(saltPos, saltPos + 12);
- return hash.equals(computeHash(password, salt, null));
+ return isEqual(hash, computeHash(password, salt, name));
}
@Override
diff --git a/src/main/java/fr/xephi/authme/security/crypts/XfBCrypt.java b/src/main/java/fr/xephi/authme/security/crypts/XfBCrypt.java
index 3ef4e4301..846807e6c 100644
--- a/src/main/java/fr/xephi/authme/security/crypts/XfBCrypt.java
+++ b/src/main/java/fr/xephi/authme/security/crypts/XfBCrypt.java
@@ -2,7 +2,7 @@ package fr.xephi.authme.security.crypts;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.security.HashUtils;
-import fr.xephi.authme.util.StringUtils;
+import fr.xephi.authme.util.ExceptionUtils;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -32,7 +32,7 @@ public class XfBCrypt implements EncryptionMethod {
try {
return HashUtils.isValidBcryptHash(hash.getHash()) && BCryptService.checkpw(password, hash.getHash());
} catch (IllegalArgumentException e) {
- ConsoleLogger.warning("XfBCrypt checkpw() returned " + StringUtils.formatException(e));
+ ConsoleLogger.warning("XfBCrypt checkpw() returned " + ExceptionUtils.formatException(e));
}
return false;
}
diff --git a/src/main/java/fr/xephi/authme/security/totp/GenerateTotpService.java b/src/main/java/fr/xephi/authme/security/totp/GenerateTotpService.java
new file mode 100644
index 000000000..14a6a6bbb
--- /dev/null
+++ b/src/main/java/fr/xephi/authme/security/totp/GenerateTotpService.java
@@ -0,0 +1,69 @@
+package fr.xephi.authme.security.totp;
+
+import fr.xephi.authme.initialization.HasCleanup;
+import fr.xephi.authme.security.totp.TotpAuthenticator.TotpGenerationResult;
+import fr.xephi.authme.util.expiring.ExpiringMap;
+import org.bukkit.entity.Player;
+
+import javax.inject.Inject;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Handles the generation of new TOTP secrets for players.
+ */
+public class GenerateTotpService implements HasCleanup {
+
+ private static final int NEW_TOTP_KEY_EXPIRATION_MINUTES = 5;
+
+ private final ExpiringMap totpKeys;
+
+ @Inject
+ private TotpAuthenticator totpAuthenticator;
+
+ GenerateTotpService() {
+ this.totpKeys = new ExpiringMap<>(NEW_TOTP_KEY_EXPIRATION_MINUTES, TimeUnit.MINUTES);
+ }
+
+ /**
+ * Generates a new TOTP key and returns the corresponding QR code.
+ *
+ * @param player the player to save the TOTP key for
+ * @return TOTP generation result
+ */
+ public TotpGenerationResult generateTotpKey(Player player) {
+ TotpGenerationResult credentials = totpAuthenticator.generateTotpKey(player);
+ totpKeys.put(player.getName().toLowerCase(), credentials);
+ return credentials;
+ }
+
+ /**
+ * Returns the generated TOTP secret of a player, if available and not yet expired.
+ *
+ * @param player the player to retrieve the TOTP key for
+ * @return TOTP generation result
+ */
+ public TotpGenerationResult getGeneratedTotpKey(Player player) {
+ return totpKeys.get(player.getName().toLowerCase());
+ }
+
+ public void removeGenerateTotpKey(Player player) {
+ totpKeys.remove(player.getName().toLowerCase());
+ }
+
+ /**
+ * Returns whether the given totp code is correct for the generated TOTP key of the player.
+ *
+ * @param player the player to verify the code for
+ * @param totpCode the totp code to verify with the generated secret
+ * @return true if the input code is correct, false if the code is invalid or no unexpired totp key is available
+ */
+ public boolean isTotpCodeCorrectForGeneratedTotpKey(Player player, String totpCode) {
+ TotpGenerationResult totpDetails = totpKeys.get(player.getName().toLowerCase());
+ return totpDetails != null && totpAuthenticator.checkCode(totpDetails.getTotpKey(), totpCode);
+ }
+
+ @Override
+ public void performCleanup() {
+ totpKeys.removeExpiredEntries();
+ }
+}
diff --git a/src/main/java/fr/xephi/authme/security/totp/TotpAuthenticator.java b/src/main/java/fr/xephi/authme/security/totp/TotpAuthenticator.java
new file mode 100644
index 000000000..4905a521e
--- /dev/null
+++ b/src/main/java/fr/xephi/authme/security/totp/TotpAuthenticator.java
@@ -0,0 +1,75 @@
+package fr.xephi.authme.security.totp;
+
+import com.google.common.primitives.Ints;
+import com.warrenstrange.googleauth.GoogleAuthenticator;
+import com.warrenstrange.googleauth.GoogleAuthenticatorKey;
+import com.warrenstrange.googleauth.GoogleAuthenticatorQRGenerator;
+import com.warrenstrange.googleauth.IGoogleAuthenticator;
+import fr.xephi.authme.data.auth.PlayerAuth;
+import fr.xephi.authme.service.BukkitService;
+import org.bukkit.entity.Player;
+
+import javax.inject.Inject;
+
+/**
+ * Provides TOTP functions (wrapping a third-party TOTP implementation).
+ */
+public class TotpAuthenticator {
+
+ private final IGoogleAuthenticator authenticator;
+ private final BukkitService bukkitService;
+
+ @Inject
+ TotpAuthenticator(BukkitService bukkitService) {
+ this.authenticator = createGoogleAuthenticator();
+ this.bukkitService = bukkitService;
+ }
+
+ /**
+ * @return new Google Authenticator instance
+ */
+ protected IGoogleAuthenticator createGoogleAuthenticator() {
+ return new GoogleAuthenticator();
+ }
+
+ public boolean checkCode(PlayerAuth auth, String totpCode) {
+ return checkCode(auth.getTotpKey(), totpCode);
+ }
+
+ /**
+ * Returns whether the given input code matches for the provided TOTP key.
+ *
+ * @param totpKey the key to check with
+ * @param inputCode the input code to verify
+ * @return true if code is valid, false otherwise
+ */
+ public boolean checkCode(String totpKey, String inputCode) {
+ Integer totpCode = Ints.tryParse(inputCode);
+ return totpCode != null && authenticator.authorize(totpKey, totpCode);
+ }
+
+ public TotpGenerationResult generateTotpKey(Player player) {
+ GoogleAuthenticatorKey credentials = authenticator.createCredentials();
+ String qrCodeUrl = GoogleAuthenticatorQRGenerator.getOtpAuthURL(
+ bukkitService.getIp(), player.getName(), credentials);
+ return new TotpGenerationResult(credentials.getKey(), qrCodeUrl);
+ }
+
+ public static final class TotpGenerationResult {
+ private final String totpKey;
+ private final String authenticatorQrCodeUrl;
+
+ public TotpGenerationResult(String totpKey, String authenticatorQrCodeUrl) {
+ this.totpKey = totpKey;
+ this.authenticatorQrCodeUrl = authenticatorQrCodeUrl;
+ }
+
+ public String getTotpKey() {
+ return totpKey;
+ }
+
+ public String getAuthenticatorQrCodeUrl() {
+ return authenticatorQrCodeUrl;
+ }
+ }
+}
diff --git a/src/main/java/fr/xephi/authme/service/AntiBotService.java b/src/main/java/fr/xephi/authme/service/AntiBotService.java
index 90c62a24b..b65400992 100644
--- a/src/main/java/fr/xephi/authme/service/AntiBotService.java
+++ b/src/main/java/fr/xephi/authme/service/AntiBotService.java
@@ -98,6 +98,9 @@ public class AntiBotService implements SettingsDependent {
disableTask = bukkitService.runTaskLater(this::stopProtection, duration * TICKS_PER_MINUTE);
}
+ /**
+ * Transitions the anti bot service from active status back to listening.
+ */
private void stopProtection() {
if (antiBotStatus != AntiBotStatus.ACTIVE) {
return;
diff --git a/src/main/java/fr/xephi/authme/service/BackupService.java b/src/main/java/fr/xephi/authme/service/BackupService.java
index 0141e8f6e..b85002eaa 100644
--- a/src/main/java/fr/xephi/authme/service/BackupService.java
+++ b/src/main/java/fr/xephi/authme/service/BackupService.java
@@ -16,8 +16,6 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
-import java.text.SimpleDateFormat;
-import java.util.Date;
import static fr.xephi.authme.util.Utils.logAndSendMessage;
import static fr.xephi.authme.util.Utils.logAndSendWarning;
@@ -27,7 +25,6 @@ import static fr.xephi.authme.util.Utils.logAndSendWarning;
*/
public class BackupService {
- private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm");
private final File dataFolder;
private final File backupFolder;
private final Settings settings;
@@ -181,7 +178,7 @@ public class BackupService {
* @return the file to back up the data to
*/
private File constructBackupFile(String fileExtension) {
- String dateString = dateFormat.format(new Date());
+ String dateString = FileUtils.createCurrentTimeString();
return new File(backupFolder, "backup" + dateString + "." + fileExtension);
}
diff --git a/src/main/java/fr/xephi/authme/service/BukkitService.java b/src/main/java/fr/xephi/authme/service/BukkitService.java
index 6c9cd0cb8..75fae29c0 100644
--- a/src/main/java/fr/xephi/authme/service/BukkitService.java
+++ b/src/main/java/fr/xephi/authme/service/BukkitService.java
@@ -12,6 +12,7 @@ import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
+import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.scheduler.BukkitRunnable;
@@ -25,6 +26,7 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
+import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
@@ -376,4 +378,26 @@ public class BukkitService implements SettingsDependent {
public BanEntry banIp(String ip, String reason, Date expires, String source) {
return Bukkit.getServer().getBanList(BanList.Type.IP).addBan(ip, reason, expires, source);
}
+
+ /**
+ * Returns an optional with a boolean indicating whether bungeecord is enabled or not if the
+ * server implementation is Spigot. Otherwise returns an empty optional.
+ *
+ * @return Optional with configuration value for Spigot, empty optional otherwise
+ */
+ public Optional isBungeeCordConfiguredForSpigot() {
+ try {
+ YamlConfiguration spigotConfig = Bukkit.spigot().getConfig();
+ return Optional.of(spigotConfig.getBoolean("settings.bungeecord"));
+ } catch (NoSuchMethodError e) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * @return the IP string that this server is bound to, otherwise empty string
+ */
+ public String getIp() {
+ return Bukkit.getServer().getIp();
+ }
}
diff --git a/src/main/java/fr/xephi/authme/service/CommonService.java b/src/main/java/fr/xephi/authme/service/CommonService.java
index 2422b1fa1..92a49267b 100644
--- a/src/main/java/fr/xephi/authme/service/CommonService.java
+++ b/src/main/java/fr/xephi/authme/service/CommonService.java
@@ -63,11 +63,12 @@ public class CommonService {
/**
* Retrieves a message in one piece.
*
+ * @param sender The entity to send the message to
* @param key the key of the message
* @return the message
*/
- public String retrieveSingleMessage(MessageKey key) {
- return messages.retrieveSingle(key);
+ public String retrieveSingleMessage(CommandSender sender, MessageKey key) {
+ return messages.retrieveSingle(sender, key);
}
/**
diff --git a/src/main/java/fr/xephi/authme/service/GeoIpService.java b/src/main/java/fr/xephi/authme/service/GeoIpService.java
index 9c43ee177..be2746878 100644
--- a/src/main/java/fr/xephi/authme/service/GeoIpService.java
+++ b/src/main/java/fr/xephi/authme/service/GeoIpService.java
@@ -1,46 +1,89 @@
package fr.xephi.authme.service;
import com.google.common.annotations.VisibleForTesting;
-import com.maxmind.geoip.LookupService;
+import com.google.common.hash.HashCode;
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+import com.google.common.io.Resources;
+import com.ice.tar.TarEntry;
+import com.ice.tar.TarInputStream;
+import com.maxmind.db.GeoIp2Provider;
+import com.maxmind.db.Reader;
+import com.maxmind.db.Reader.FileMode;
+import com.maxmind.db.cache.CHMCache;
+import com.maxmind.db.model.Country;
+import com.maxmind.db.model.CountryResponse;
+
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.initialization.DataFolder;
import fr.xephi.authme.util.FileUtils;
import fr.xephi.authme.util.InternetProtocolUtils;
-import javax.inject.Inject;
+import java.io.BufferedInputStream;
import java.io.File;
-import java.io.FileOutputStream;
+import java.io.FileNotFoundException;
import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.InetAddress;
import java.net.URL;
-import java.net.URLConnection;
-import java.util.concurrent.TimeUnit;
+import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Objects;
+import java.util.Optional;
import java.util.zip.GZIPInputStream;
-import static com.maxmind.geoip.LookupService.GEOIP_MEMORY_CACHE;
+import javax.inject.Inject;
public class GeoIpService {
- private static final String LICENSE =
- "[LICENSE] This product uses data from the GeoLite API created by MaxMind, available at http://www.maxmind.com";
- private static final String GEOIP_URL =
- "http://geolite.maxmind.com/download/geoip/database/GeoLiteCountry/GeoIP.dat.gz";
- private LookupService lookupService;
- private Thread downloadTask;
- private final File dataFile;
+ private static final String LICENSE =
+ "[LICENSE] This product includes GeoLite2 data created by MaxMind, available at https://www.maxmind.com";
+
+ private static final String DATABASE_NAME = "GeoLite2-Country";
+ private static final String DATABASE_EXT = ".mmdb";
+ private static final String DATABASE_FILE = DATABASE_NAME + DATABASE_EXT;
+
+ private static final String ARCHIVE_FILE = DATABASE_NAME + ".tar.gz";
+
+ private static final String ARCHIVE_URL = "https://geolite.maxmind.com/download/geoip/database/" + ARCHIVE_FILE;
+ private static final String CHECKSUM_URL = ARCHIVE_URL + ".md5";
+
+ private static final int UPDATE_INTERVAL_DAYS = 30;
+
+ // The server for MaxMind doesn't seem to understand RFC1123,
+ // but every HTTP implementation have to support RFC 1023
+ private static final String TIME_RFC_1023 = "EEE, dd-MMM-yy HH:mm:ss zzz";
+
+ private final Path dataFile;
+ private final BukkitService bukkitService;
+
+ private GeoIp2Provider databaseReader;
+ private volatile boolean downloading;
@Inject
- GeoIpService(@DataFolder File dataFolder) {
- this.dataFile = new File(dataFolder, "GeoIP.dat");
+ GeoIpService(@DataFolder File dataFolder, BukkitService bukkitService) {
+ this.bukkitService = bukkitService;
+ this.dataFile = dataFolder.toPath().resolve(DATABASE_FILE);
+
// Fires download of recent data or the initialization of the look up service
isDataAvailable();
}
@VisibleForTesting
- GeoIpService(@DataFolder File dataFolder, LookupService lookupService) {
- this.dataFile = dataFolder;
- this.lookupService = lookupService;
+ GeoIpService(@DataFolder File dataFolder, BukkitService bukkitService, GeoIp2Provider reader) {
+ this.bukkitService = bukkitService;
+ this.dataFile = dataFolder.toPath().resolve(DATABASE_FILE);
+
+ this.databaseReader = reader;
}
/**
@@ -49,94 +92,227 @@ public class GeoIpService {
* @return True if the data is available, false otherwise.
*/
private synchronized boolean isDataAvailable() {
- if (downloadTask != null && downloadTask.isAlive()) {
+ if (downloading) {
+ // we are currently downloading the database
return false;
}
- if (lookupService != null) {
+
+ if (databaseReader != null) {
+ // everything is initialized
return true;
}
- if (dataFile.exists()) {
- boolean dataIsOld = (System.currentTimeMillis() - dataFile.lastModified()) > TimeUnit.DAYS.toMillis(30);
- if (!dataIsOld) {
- try {
- lookupService = new LookupService(dataFile, GEOIP_MEMORY_CACHE);
- ConsoleLogger.info(LICENSE);
+ if (Files.exists(dataFile)) {
+ try {
+ FileTime lastModifiedTime = Files.getLastModifiedTime(dataFile);
+ if (Duration.between(lastModifiedTime.toInstant(), Instant.now()).toDays() <= UPDATE_INTERVAL_DAYS) {
+ startReading();
+
+ // don't fire the update task - we are up to date
return true;
- } catch (IOException e) {
- ConsoleLogger.logException("Failed to load GeoLiteAPI database", e);
- return false;
+ } else {
+ ConsoleLogger.debug("GEO IP database is older than " + UPDATE_INTERVAL_DAYS + " Days");
}
- } else {
- FileUtils.delete(dataFile);
+ } catch (IOException ioEx) {
+ ConsoleLogger.logException("Failed to load GeoLiteAPI database", ioEx);
+ return false;
}
}
- // Ok, let's try to download the data file!
- downloadTask = createDownloadTask();
- downloadTask.start();
+
+ //set the downloading flag in order to fix race conditions outside
+ downloading = true;
+
+ // File is outdated or doesn't exist - let's try to download the data file!
+ // use bukkit's cached threads
+ bukkitService.runTaskAsynchronously(this::updateDatabase);
return false;
}
/**
- * Create a thread which will attempt to download new data from the GeoLite website.
- *
- * @return the generated download thread
+ * Tries to update the database by downloading a new version from the website.
*/
- private Thread createDownloadTask() {
- return new Thread(new Runnable() {
- @Override
- public void run() {
- try {
- URL downloadUrl = new URL(GEOIP_URL);
- URLConnection conn = downloadUrl.openConnection();
- conn.setConnectTimeout(10000);
- conn.connect();
- InputStream input = conn.getInputStream();
- if (conn.getURL().toString().endsWith(".gz")) {
- input = new GZIPInputStream(input);
- }
- OutputStream output = new FileOutputStream(dataFile);
- byte[] buffer = new byte[2048];
- int length = input.read(buffer);
- while (length >= 0) {
- output.write(buffer, 0, length);
- length = input.read(buffer);
- }
- output.close();
- input.close();
- } catch (IOException e) {
- ConsoleLogger.logException("Could not download GeoLiteAPI database", e);
- }
+ private void updateDatabase() {
+ ConsoleLogger.info("Downloading GEO IP database, because the old database is older than "
+ + UPDATE_INTERVAL_DAYS + " days or doesn't exist");
+
+ Path tempFile = null;
+ try {
+ // download database to temporarily location
+ tempFile = Files.createTempFile(ARCHIVE_FILE, null);
+ if (!downloadDatabaseArchive(tempFile)) {
+ ConsoleLogger.info("There is no newer GEO IP database uploaded to MaxMind. Using the old one for now.");
+ startReading();
+ return;
}
- });
+
+ // MD5 checksum verification
+ String expectedChecksum = Resources.toString(new URL(CHECKSUM_URL), StandardCharsets.UTF_8);
+ verifyChecksum(Hashing.md5(), tempFile, expectedChecksum);
+
+ // tar extract database and copy to target destination
+ extractDatabase(tempFile, dataFile);
+
+ //only set this value to false on success otherwise errors could lead to endless download triggers
+ ConsoleLogger.info("Successfully downloaded new GEO IP database to " + dataFile);
+ startReading();
+ } catch (IOException ioEx) {
+ ConsoleLogger.logException("Could not download GeoLiteAPI database", ioEx);
+ } finally {
+ // clean up
+ if (tempFile != null) {
+ FileUtils.delete(tempFile.toFile());
+ }
+ }
+ }
+
+ private void startReading() throws IOException {
+ databaseReader = new Reader(dataFile.toFile(), FileMode.MEMORY, new CHMCache());
+ ConsoleLogger.info(LICENSE);
+
+ // clear downloading flag, because we now have working reader instance
+ downloading = false;
+ }
+
+ /**
+ * Downloads the archive to the destination file if it's newer than the locally version.
+ *
+ * @param lastModified modification timestamp of the already present file
+ * @param destination save file
+ * @return false if we already have the newest version, true if successful
+ * @throws IOException if failed during downloading and writing to destination file
+ */
+ private boolean downloadDatabaseArchive(Instant lastModified, Path destination) throws IOException {
+ HttpURLConnection connection = (HttpURLConnection) new URL(ARCHIVE_URL).openConnection();
+ if (lastModified != null) {
+ // Only download if we actually need a newer version - this field is specified in GMT zone
+ ZonedDateTime zonedTime = lastModified.atZone(ZoneId.of("GMT"));
+ String timeFormat = DateTimeFormatter.ofPattern(TIME_RFC_1023).format(zonedTime);
+ connection.addRequestProperty("If-Modified-Since", timeFormat);
+ }
+
+ if (connection.getResponseCode() == HttpURLConnection.HTTP_NOT_MODIFIED) {
+ //we already have the newest version
+ connection.getInputStream().close();
+ return false;
+ }
+
+ Files.copy(connection.getInputStream(), destination, StandardCopyOption.REPLACE_EXISTING);
+ return true;
+ }
+
+ /**
+ * Downloads the archive to the destination file if it's newer than the locally version.
+ *
+ * @param destination save file
+ * @return false if we already have the newest version, true if successful
+ * @throws IOException if failed during downloading and writing to destination file
+ */
+ private boolean downloadDatabaseArchive(Path destination) throws IOException {
+ Instant lastModified = null;
+ if (Files.exists(dataFile)) {
+ lastModified = Files.getLastModifiedTime(dataFile).toInstant();
+ }
+
+ return downloadDatabaseArchive(lastModified, destination);
+ }
+
+ /**
+ * Verify if the expected checksum is equal to the checksum of the given file.
+ *
+ * @param function the checksum function like MD5, SHA256 used to generate the checksum from the file
+ * @param file the file we want to calculate the checksum from
+ * @param expectedChecksum the expected checksum
+ * @throws IOException on I/O error reading the file or the checksum verification failed
+ */
+ private void verifyChecksum(HashFunction function, Path file, String expectedChecksum) throws IOException {
+ HashCode actualHash = function.hashBytes(Files.readAllBytes(file));
+ HashCode expectedHash = HashCode.fromString(expectedChecksum);
+ if (!Objects.equals(actualHash, expectedHash)) {
+ throw new IOException("GEO IP Checksum verification failed. " +
+ "Expected: " + expectedChecksum + "Actual:" + actualHash);
+ }
+ }
+
+ /**
+ * Extract the database from the tar archive. Existing outputFile will be replaced if it already exists.
+ *
+ * @param tarInputFile gzipped tar input file where the database is
+ * @param outputFile destination file for the database
+ * @throws IOException on I/O error reading the tar archive, or writing the output
+ * @throws FileNotFoundException if the database cannot be found inside the archive
+ */
+ private void extractDatabase(Path tarInputFile, Path outputFile) throws FileNotFoundException, IOException {
+ // .gz -> gzipped file
+ try (BufferedInputStream in = new BufferedInputStream(Files.newInputStream(tarInputFile));
+ TarInputStream tarIn = new TarInputStream(new GZIPInputStream(in))) {
+ for (TarEntry entry = tarIn.getNextEntry(); entry != null; entry = tarIn.getNextEntry()) {
+ // filename including folders (absolute path inside the archive)
+ String filename = entry.getName();
+ if (entry.isDirectory() || !filename.endsWith(DATABASE_EXT)) {
+ continue;
+ }
+
+ // found the database file and copy file
+ Files.copy(tarIn, outputFile, StandardCopyOption.REPLACE_EXISTING);
+
+ // update the last modification date to be same as in the archive
+ Files.setLastModifiedTime(outputFile, FileTime.from(entry.getModTime().toInstant()));
+ return;
+ }
+ }
+
+ throw new FileNotFoundException("Cannot find database inside downloaded GEO IP file at " + tarInputFile);
}
/**
* Get the country code of the given IP address.
*
* @param ip textual IP address to lookup.
- *
- * @return two-character ISO 3166-1 alpha code for the country.
+ * @return two-character ISO 3166-1 alpha code for the country or "--" if it cannot be fetched.
*/
public String getCountryCode(String ip) {
- if (!InternetProtocolUtils.isLocalAddress(ip) && isDataAvailable()) {
- return lookupService.getCountry(ip).getCode();
- }
- return "--";
+ return getCountry(ip).map(Country::getIsoCode).orElse("--");
}
/**
* Get the country name of the given IP address.
*
* @param ip textual IP address to lookup.
- *
- * @return The name of the country.
+ * @return The name of the country or "N/A" if it cannot be fetched.
*/
public String getCountryName(String ip) {
- if (!InternetProtocolUtils.isLocalAddress(ip) && isDataAvailable()) {
- return lookupService.getCountry(ip).getName();
- }
- return "N/A";
+ return getCountry(ip).map(Country::getName).orElse("N/A");
}
+ /**
+ * Get the country of the given IP address
+ *
+ * @param ip textual IP address to lookup
+ * @return the wrapped Country model or {@link Optional#empty()} if
+ *
+ * - Database reader isn't initialized
+ * - MaxMind has no record about this IP address
+ * - IP address is local
+ * - Textual representation is not a valid IP address
+ *
+ */
+ private Optional getCountry(String ip) {
+ if (ip == null || ip.isEmpty() || InternetProtocolUtils.isLocalAddress(ip) || !isDataAvailable()) {
+ return Optional.empty();
+ }
+
+ try {
+ InetAddress address = InetAddress.getByName(ip);
+
+ // Reader.getCountry() can be null for unknown addresses
+ return Optional.ofNullable(databaseReader.getCountry(address)).map(CountryResponse::getCountry);
+ } catch (UnknownHostException e) {
+ // Ignore invalid ip addresses
+ // Legacy GEO IP Database returned a unknown country object with Country-Code: '--' and Country-Name: 'N/A'
+ } catch (IOException ioEx) {
+ ConsoleLogger.logException("Cannot lookup country for " + ip + " at GEO IP database", ioEx);
+ }
+
+ return Optional.empty();
+ }
}
diff --git a/src/main/java/fr/xephi/authme/service/HelpTranslationGenerator.java b/src/main/java/fr/xephi/authme/service/HelpTranslationGenerator.java
index 6ecd05490..21407b4f0 100644
--- a/src/main/java/fr/xephi/authme/service/HelpTranslationGenerator.java
+++ b/src/main/java/fr/xephi/authme/service/HelpTranslationGenerator.java
@@ -44,15 +44,17 @@ public class HelpTranslationGenerator {
/**
* Updates the help file to contain entries for all commands.
*
+ * @return the help file that has been updated
* @throws IOException if the help file cannot be written to
*/
- public void updateHelpFile() throws IOException {
+ public File updateHelpFile() throws IOException {
String languageCode = settings.getProperty(PluginSettings.MESSAGES_LANGUAGE);
File helpFile = new File(dataFolder, "messages/help_" + languageCode + ".yml");
Map helpEntries = generateHelpMessageEntries();
String helpEntriesYaml = exportToYaml(helpEntries);
Files.write(helpFile.toPath(), helpEntriesYaml.getBytes(), StandardOpenOption.TRUNCATE_EXISTING);
+ return helpFile;
}
private static String exportToYaml(Map helpEntries) {
diff --git a/src/main/java/fr/xephi/authme/service/MessageUpdater.java b/src/main/java/fr/xephi/authme/service/MessageUpdater.java
deleted file mode 100644
index 67cfe617e..000000000
--- a/src/main/java/fr/xephi/authme/service/MessageUpdater.java
+++ /dev/null
@@ -1,138 +0,0 @@
-package fr.xephi.authme.service;
-
-import ch.jalu.configme.SettingsManager;
-import ch.jalu.configme.configurationdata.ConfigurationData;
-import ch.jalu.configme.properties.Property;
-import ch.jalu.configme.properties.StringProperty;
-import ch.jalu.configme.resource.YamlFileResource;
-import fr.xephi.authme.ConsoleLogger;
-import fr.xephi.authme.message.MessageKey;
-import fr.xephi.authme.util.FileUtils;
-import fr.xephi.authme.util.StringUtils;
-import org.bukkit.command.CommandSender;
-import org.bukkit.configuration.file.FileConfiguration;
-import org.bukkit.configuration.file.YamlConfiguration;
-
-import java.io.Closeable;
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.util.Arrays;
-import java.util.List;
-import java.util.stream.Collectors;
-
-/**
- * Updates a user's messages file with messages from the JAR files.
- */
-public class MessageUpdater {
-
- private final FileConfiguration userConfiguration;
- private final FileConfiguration localJarConfiguration;
- private final FileConfiguration defaultJarConfiguration;
-
- private final List> properties;
- private final SettingsManager settingsManager;
- private boolean hasMissingMessages = false;
-
- /**
- * Constructor.
- *
- * @param userFile messages file in the data folder
- * @param localJarFile path to messages file in JAR in local language
- * @param defaultJarFile path to messages file in JAR for default language
- * @throws Exception if userFile does not exist or no JAR messages file can be loaded
- */
- public MessageUpdater(File userFile, String localJarFile, String defaultJarFile) throws Exception {
- if (!userFile.exists()) {
- throw new Exception("Local messages file does not exist");
- }
-
- userConfiguration = YamlConfiguration.loadConfiguration(userFile);
- localJarConfiguration = loadJarFileOrSendError(localJarFile);
- defaultJarConfiguration = localJarFile.equals(defaultJarFile) ? null : loadJarFileOrSendError(defaultJarFile);
- if (localJarConfiguration == null && defaultJarConfiguration == null) {
- throw new Exception("Could not load any JAR messages file to copy from");
- }
-
- properties = buildPropertyEntriesForMessageKeys();
- settingsManager = new SettingsManager(
- new YamlFileResource(userFile), null, new ConfigurationData(properties));
- }
-
- /**
- * Copies missing messages to the messages file.
- *
- * @param sender sender starting the copy process
- * @return true if the messages file was updated, false otherwise
- * @throws Exception if an error occurs during saving
- */
- public boolean executeCopy(CommandSender sender) throws Exception {
- copyMissingMessages();
-
- if (!hasMissingMessages) {
- sender.sendMessage("No new messages to add");
- return false;
- }
-
- // Save user configuration file
- try {
- settingsManager.save();
- sender.sendMessage("Message file updated with new messages");
- return true;
- } catch (Exception e) {
- throw new Exception("Could not save to messages file: " + StringUtils.formatException(e));
- }
- }
-
- private void copyMissingMessages() {
- for (Property property : properties) {
- String message = userConfiguration.getString(property.getPath());
- if (message == null) {
- hasMissingMessages = true;
- message = getMessageFromJar(property.getPath());
- }
- settingsManager.setProperty(property, message);
- }
- }
-
- private String getMessageFromJar(String key) {
- String message = (localJarConfiguration == null ? null : localJarConfiguration.getString(key));
- if (message != null) {
- return message;
- }
- return (defaultJarConfiguration == null) ? null : defaultJarConfiguration.getString(key);
- }
-
- private static FileConfiguration loadJarFileOrSendError(String jarPath) {
- try (InputStream stream = FileUtils.getResourceFromJar(jarPath)) {
- if (stream == null) {
- ConsoleLogger.info("Could not load '" + jarPath + "' from JAR");
- return null;
- }
- InputStreamReader isr = new InputStreamReader(stream);
- FileConfiguration configuration = YamlConfiguration.loadConfiguration(isr);
- close(isr);
- return configuration;
- } catch (IOException e) {
- ConsoleLogger.logException("Exception while handling JAR path '" + jarPath + "'", e);
- }
- return null;
- }
-
- private static List> buildPropertyEntriesForMessageKeys() {
- return Arrays.stream(MessageKey.values())
- .map(key -> new StringProperty(key.getKey(), ""))
- .collect(Collectors.toList());
- }
-
- private static void close(Closeable closeable) {
- if (closeable != null) {
- try {
- closeable.close();
- } catch (IOException e) {
- ConsoleLogger.info("Cannot close '" + closeable + "': " + StringUtils.formatException(e));
- }
- }
- }
-}
diff --git a/src/main/java/fr/xephi/authme/service/PasswordRecoveryService.java b/src/main/java/fr/xephi/authme/service/PasswordRecoveryService.java
index f24b45f35..200aa11c7 100644
--- a/src/main/java/fr/xephi/authme/service/PasswordRecoveryService.java
+++ b/src/main/java/fr/xephi/authme/service/PasswordRecoveryService.java
@@ -121,6 +121,16 @@ public class PasswordRecoveryService implements Reloadable, HasCleanup {
commonService.send(player, MessageKey.RECOVERY_CHANGE_PASSWORD);
}
+ /**
+ * Removes a player from the list of successful recovers so that he can
+ * no longer use the /email setpassword command.
+ *
+ * @param player The player to remove.
+ */
+ public void removeFromSuccessfulRecovery(Player player) {
+ successfulRecovers.remove(player.getName());
+ }
+
/**
* Check if a player is able to have emails sent.
*
@@ -149,12 +159,7 @@ public class PasswordRecoveryService implements Reloadable, HasCleanup {
String playerAddress = PlayerUtils.getPlayerIp(player);
String storedAddress = successfulRecovers.get(name);
- if (storedAddress == null || !playerAddress.equals(storedAddress)) {
- messages.send(player, MessageKey.CHANGE_PASSWORD_EXPIRED);
- return false;
- }
-
- return true;
+ return storedAddress != null && playerAddress.equals(storedAddress);
}
@Override
diff --git a/src/main/java/fr/xephi/authme/service/SessionService.java b/src/main/java/fr/xephi/authme/service/SessionService.java
index dd676cc87..f821a64b3 100644
--- a/src/main/java/fr/xephi/authme/service/SessionService.java
+++ b/src/main/java/fr/xephi/authme/service/SessionService.java
@@ -45,11 +45,13 @@ public class SessionService implements Reloadable {
database.setUnlogged(name);
database.revokeSession(name);
PlayerAuth auth = database.getAuth(name);
- if (hasValidSessionData(auth, player)) {
+
+ SessionState state = fetchSessionStatus(auth, player);
+ if (state.equals(SessionState.VALID)) {
RestoreSessionEvent event = bukkitService.createAndCallEvent(
isAsync -> new RestoreSessionEvent(player, isAsync));
return !event.isCancelled();
- } else {
+ } else if (state.equals(SessionState.IP_CHANGED)) {
service.send(player, MessageKey.SESSION_EXPIRED);
}
}
@@ -62,19 +64,26 @@ public class SessionService implements Reloadable {
*
* @param auth the player auth
* @param player the associated player
- * @return true if the player may resume his login session, false otherwise
+ * @return SessionState based on the state of the session (VALID, NOT_VALID, OUTDATED, IP_CHANGED)
*/
- private boolean hasValidSessionData(PlayerAuth auth, Player player) {
+ private SessionState fetchSessionStatus(PlayerAuth auth, Player player) {
if (auth == null) {
ConsoleLogger.warning("No PlayerAuth in database for '" + player.getName() + "' during session check");
- return false;
+ return SessionState.NOT_VALID;
} else if (auth.getLastLogin() == null) {
- return false;
+ return SessionState.NOT_VALID;
}
long timeSinceLastLogin = System.currentTimeMillis() - auth.getLastLogin();
- return PlayerUtils.getPlayerIp(player).equals(auth.getLastIp())
- && timeSinceLastLogin > 0
- && timeSinceLastLogin < service.getProperty(PluginSettings.SESSIONS_TIMEOUT) * MILLIS_PER_MINUTE;
+
+ if (timeSinceLastLogin > 0
+ && timeSinceLastLogin < service.getProperty(PluginSettings.SESSIONS_TIMEOUT) * MILLIS_PER_MINUTE) {
+ if (PlayerUtils.getPlayerIp(player).equals(auth.getLastIp())) {
+ return SessionState.VALID;
+ } else {
+ return SessionState.IP_CHANGED;
+ }
+ }
+ return SessionState.OUTDATED;
}
public void grantSession(String name) {
diff --git a/src/main/java/fr/xephi/authme/service/SessionState.java b/src/main/java/fr/xephi/authme/service/SessionState.java
new file mode 100644
index 000000000..801f36bc8
--- /dev/null
+++ b/src/main/java/fr/xephi/authme/service/SessionState.java
@@ -0,0 +1,13 @@
+package fr.xephi.authme.service;
+
+public enum SessionState {
+
+ VALID,
+
+ NOT_VALID,
+
+ OUTDATED,
+
+ IP_CHANGED
+
+}
diff --git a/src/main/java/fr/xephi/authme/service/TeleportationService.java b/src/main/java/fr/xephi/authme/service/TeleportationService.java
index 38917d4d8..1588c4404 100644
--- a/src/main/java/fr/xephi/authme/service/TeleportationService.java
+++ b/src/main/java/fr/xephi/authme/service/TeleportationService.java
@@ -1,5 +1,6 @@
package fr.xephi.authme.service;
+import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.data.auth.PlayerAuth;
import fr.xephi.authme.data.auth.PlayerCache;
import fr.xephi.authme.data.limbo.LimboPlayer;
@@ -63,12 +64,13 @@ public class TeleportationService implements Reloadable {
public void teleportOnJoin(final Player player) {
if (!settings.getProperty(RestrictionSettings.NO_TELEPORT)
&& settings.getProperty(TELEPORT_UNAUTHED_TO_SPAWN)) {
+ ConsoleLogger.debug("Teleport on join for player `{0}`", player.getName());
teleportToSpawn(player, playerCache.isAuthenticated(player.getName()));
}
}
/**
- * Returns the player's custom on join location
+ * Returns the player's custom on join location.
*
* @param player the player to process
*
@@ -79,12 +81,14 @@ public class TeleportationService implements Reloadable {
&& settings.getProperty(TELEPORT_UNAUTHED_TO_SPAWN)) {
final Location location = spawnLoader.getSpawnLocation(player);
- SpawnTeleportEvent event = new SpawnTeleportEvent(player, location, playerCache.isAuthenticated(player.getName()));
+ SpawnTeleportEvent event = new SpawnTeleportEvent(player, location,
+ playerCache.isAuthenticated(player.getName()));
bukkitService.callEvent(event);
- if(!isEventValid(event)) {
+ if (!isEventValid(event)) {
return null;
}
+ ConsoleLogger.debug("Returning custom location for >1.9 join event for player `{0}`", player.getName());
return location;
}
return null;
@@ -106,6 +110,7 @@ public class TeleportationService implements Reloadable {
}
if (!player.hasPlayedBefore() || !dataSource.isAuthAvailable(player.getName())) {
+ ConsoleLogger.debug("Attempting to teleport player `{0}` to first spawn", player.getName());
performTeleportation(player, new FirstSpawnTeleportEvent(player, firstSpawn));
}
}
@@ -129,12 +134,15 @@ public class TeleportationService implements Reloadable {
// The world in LimboPlayer is from where the player comes, before any teleportation by AuthMe
if (mustForceSpawnAfterLogin(worldName)) {
+ ConsoleLogger.debug("Teleporting `{0}` to spawn because of 'force-spawn after login'", player.getName());
teleportToSpawn(player, true);
} else if (settings.getProperty(TELEPORT_UNAUTHED_TO_SPAWN)) {
if (settings.getProperty(RestrictionSettings.SAVE_QUIT_LOCATION) && auth.getQuitLocY() != 0) {
Location location = buildLocationFromAuth(player, auth);
+ ConsoleLogger.debug("Teleporting `{0}` after login, based on the player auth", player.getName());
teleportBackFromSpawn(player, location);
} else if (limbo != null && limbo.getLocation() != null) {
+ ConsoleLogger.debug("Teleporting `{0}` after login, based on the limbo player", player.getName());
teleportBackFromSpawn(player, limbo.getLocation());
}
}
diff --git a/src/main/java/fr/xephi/authme/service/bungeecord/BungeeSender.java b/src/main/java/fr/xephi/authme/service/bungeecord/BungeeSender.java
index 0b5e6ca64..84fda6956 100644
--- a/src/main/java/fr/xephi/authme/service/bungeecord/BungeeSender.java
+++ b/src/main/java/fr/xephi/authme/service/bungeecord/BungeeSender.java
@@ -12,7 +12,6 @@ import org.bukkit.entity.Player;
import org.bukkit.plugin.messaging.Messenger;
import javax.inject.Inject;
-import java.io.Console;
public class BungeeSender implements SettingsDependent {
@@ -66,7 +65,7 @@ public class BungeeSender implements SettingsDependent {
public void connectPlayerOnLogin(Player player) {
if (isEnabled && !destinationServerOnLogin.isEmpty()) {
bukkitService.scheduleSyncDelayedTask(() ->
- sendBungeecordMessage("ConnectOther", player.getName(), destinationServerOnLogin), 20L);
+ sendBungeecordMessage("ConnectOther", player.getName(), destinationServerOnLogin), 5L);
}
}
diff --git a/src/main/java/fr/xephi/authme/service/yaml/YamlFileResourceProvider.java b/src/main/java/fr/xephi/authme/service/yaml/YamlFileResourceProvider.java
new file mode 100644
index 000000000..b13294371
--- /dev/null
+++ b/src/main/java/fr/xephi/authme/service/yaml/YamlFileResourceProvider.java
@@ -0,0 +1,30 @@
+package fr.xephi.authme.service.yaml;
+
+import ch.jalu.configme.resource.YamlFileResource;
+import org.yaml.snakeyaml.parser.ParserException;
+
+import java.io.File;
+
+/**
+ * Creates {@link YamlFileResource} objects.
+ */
+public final class YamlFileResourceProvider {
+
+ private YamlFileResourceProvider() {
+ }
+
+ /**
+ * Creates a {@link YamlFileResource} instance for the given file. Wraps SnakeYAML's parse exception
+ * into an AuthMe exception.
+ *
+ * @param file the file to load
+ * @return the generated resource
+ */
+ public static YamlFileResource loadFromFile(File file) {
+ try {
+ return new YamlFileResource(file);
+ } catch (ParserException e) {
+ throw new YamlParseException(file.getPath(), e);
+ }
+ }
+}
diff --git a/src/main/java/fr/xephi/authme/service/yaml/YamlParseException.java b/src/main/java/fr/xephi/authme/service/yaml/YamlParseException.java
new file mode 100644
index 000000000..b070bcf35
--- /dev/null
+++ b/src/main/java/fr/xephi/authme/service/yaml/YamlParseException.java
@@ -0,0 +1,26 @@
+package fr.xephi.authme.service.yaml;
+
+import org.yaml.snakeyaml.parser.ParserException;
+
+/**
+ * Exception when a YAML file could not be parsed.
+ */
+public class YamlParseException extends RuntimeException {
+
+ private final String file;
+
+ /**
+ * Constructor.
+ *
+ * @param file the file a parsing exception occurred with
+ * @param snakeYamlException the caught exception from SnakeYAML
+ */
+ public YamlParseException(String file, ParserException snakeYamlException) {
+ super(snakeYamlException);
+ this.file = file;
+ }
+
+ public String getFile() {
+ return file;
+ }
+}
diff --git a/src/main/java/fr/xephi/authme/settings/SettingsMigrationService.java b/src/main/java/fr/xephi/authme/settings/SettingsMigrationService.java
index c5ab3fd7a..d95f73ce5 100644
--- a/src/main/java/fr/xephi/authme/settings/SettingsMigrationService.java
+++ b/src/main/java/fr/xephi/authme/settings/SettingsMigrationService.java
@@ -10,6 +10,7 @@ import fr.xephi.authme.output.LogLevel;
import fr.xephi.authme.process.register.RegisterSecondaryArgument;
import fr.xephi.authme.process.register.RegistrationType;
import fr.xephi.authme.security.HashAlgorithm;
+import fr.xephi.authme.settings.properties.DatabaseSettings;
import fr.xephi.authme.settings.properties.PluginSettings;
import fr.xephi.authme.settings.properties.RegistrationSettings;
import fr.xephi.authme.settings.properties.SecuritySettings;
@@ -74,6 +75,7 @@ public class SettingsMigrationService extends PlainMigrationService {
| convertToRegistrationType(resource)
| mergeAndMovePermissionGroupSettings(resource)
| moveDeprecatedHashAlgorithmIntoLegacySection(resource)
+ | moveSaltColumnConfigWithOtherColumnConfigs(resource)
|| hasDeprecatedProperties(resource);
}
@@ -313,6 +315,18 @@ public class SettingsMigrationService extends PlainMigrationService {
return false;
}
+ /**
+ * Moves the property for the password salt column name to the same path as all other column name properties.
+ *
+ * @param resource The property resource
+ * @return True if the configuration has changed, false otherwise
+ */
+ private static boolean moveSaltColumnConfigWithOtherColumnConfigs(PropertyResource resource) {
+ Property oldProperty = newProperty("ExternalBoardOptions.mySQLColumnSalt",
+ DatabaseSettings.MYSQL_COL_SALT.getDefaultValue());
+ return moveProperty(oldProperty, DatabaseSettings.MYSQL_COL_SALT, resource);
+ }
+
/**
* Retrieves the old config to run a command when alt accounts are detected and sets them to this instance
* for further processing.
diff --git a/src/main/java/fr/xephi/authme/settings/SettingsWarner.java b/src/main/java/fr/xephi/authme/settings/SettingsWarner.java
index 176dca9c9..d055dd0fd 100644
--- a/src/main/java/fr/xephi/authme/settings/SettingsWarner.java
+++ b/src/main/java/fr/xephi/authme/settings/SettingsWarner.java
@@ -4,12 +4,15 @@ import fr.xephi.authme.AuthMe;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.security.HashAlgorithm;
import fr.xephi.authme.security.crypts.Argon2;
+import fr.xephi.authme.service.BukkitService;
import fr.xephi.authme.settings.properties.EmailSettings;
+import fr.xephi.authme.settings.properties.HooksSettings;
import fr.xephi.authme.settings.properties.PluginSettings;
import fr.xephi.authme.settings.properties.RestrictionSettings;
import fr.xephi.authme.settings.properties.SecuritySettings;
import javax.inject.Inject;
+import java.util.Optional;
/**
* Logs warning messages in cases where the configured values suggest a misconfiguration.
@@ -26,6 +29,9 @@ public class SettingsWarner {
@Inject
private AuthMe authMe;
+ @Inject
+ private BukkitService bukkitService;
+
SettingsWarner() {
}
@@ -50,6 +56,14 @@ public class SettingsWarner {
ConsoleLogger.warning("Warning: Session timeout needs to be positive in order to work!");
}
+ // Warn if spigot.yml has settings.bungeecord set to true but config.yml has Hooks.bungeecord set to false
+ if (isTrue(bukkitService.isBungeeCordConfiguredForSpigot())
+ && !settings.getProperty(HooksSettings.BUNGEECORD)) {
+ ConsoleLogger.warning("Note: Hooks.bungeecord is set to false but your server appears to be running in"
+ + " bungeecord mode (see your spigot.yml). In order to allow the datasource caching and the"
+ + " AuthMeBungee add-on to work properly you have to enable this option!");
+ }
+
// Check if argon2 library is present and can be loaded
if (settings.getProperty(SecuritySettings.PASSWORD_HASH).equals(HashAlgorithm.ARGON2)
&& !Argon2.isLibraryLoaded()) {
@@ -58,4 +72,8 @@ public class SettingsWarner {
authMe.stopOrUnload();
}
}
+
+ private static boolean isTrue(Optional value) {
+ return value.isPresent() && value.get();
+ }
}
diff --git a/src/main/java/fr/xephi/authme/settings/SpawnLoader.java b/src/main/java/fr/xephi/authme/settings/SpawnLoader.java
index ea235b3cc..d2f2edbf7 100644
--- a/src/main/java/fr/xephi/authme/settings/SpawnLoader.java
+++ b/src/main/java/fr/xephi/authme/settings/SpawnLoader.java
@@ -198,9 +198,11 @@ public class SpawnLoader implements Reloadable {
// ignore
}
if (spawnLoc != null) {
+ ConsoleLogger.debug("Spawn location determined as `{0}` for world `{1}`", spawnLoc, world.getName());
return spawnLoc;
}
}
+ ConsoleLogger.debug("Fall back to default world spawn location. World: `{0}`", world.getName());
return world.getSpawnLocation(); // return default location
}
diff --git a/src/main/java/fr/xephi/authme/settings/commandconfig/CommandManager.java b/src/main/java/fr/xephi/authme/settings/commandconfig/CommandManager.java
index d4593be96..0531496cb 100644
--- a/src/main/java/fr/xephi/authme/settings/commandconfig/CommandManager.java
+++ b/src/main/java/fr/xephi/authme/settings/commandconfig/CommandManager.java
@@ -1,11 +1,11 @@
package fr.xephi.authme.settings.commandconfig;
import ch.jalu.configme.SettingsManager;
-import ch.jalu.configme.resource.YamlFileResource;
import fr.xephi.authme.initialization.DataFolder;
import fr.xephi.authme.initialization.Reloadable;
import fr.xephi.authme.service.BukkitService;
import fr.xephi.authme.service.GeoIpService;
+import fr.xephi.authme.service.yaml.YamlFileResourceProvider;
import fr.xephi.authme.util.FileUtils;
import fr.xephi.authme.util.PlayerUtils;
import fr.xephi.authme.util.lazytags.Tag;
@@ -151,7 +151,7 @@ public class CommandManager implements Reloadable {
FileUtils.copyFileFromResource(file, "commands.yml");
SettingsManager settingsManager = new SettingsManager(
- new YamlFileResource(file), commandMigrationService, CommandSettingsHolder.class);
+ YamlFileResourceProvider.loadFromFile(file), commandMigrationService, CommandSettingsHolder.class);
CommandConfig commandConfig = settingsManager.getProperty(CommandSettingsHolder.COMMANDS);
onJoinCommands = newReplacer(commandConfig.getOnJoin());
onLoginCommands = newOnLoginCmdReplacer(commandConfig.getOnLogin());
diff --git a/src/main/java/fr/xephi/authme/settings/properties/DatabaseSettings.java b/src/main/java/fr/xephi/authme/settings/properties/DatabaseSettings.java
index 66ddd3cd5..0818c2693 100644
--- a/src/main/java/fr/xephi/authme/settings/properties/DatabaseSettings.java
+++ b/src/main/java/fr/xephi/authme/settings/properties/DatabaseSettings.java
@@ -65,7 +65,7 @@ public final class DatabaseSettings implements SettingsHolder {
@Comment("Column for storing players passwords salts")
public static final Property MYSQL_COL_SALT =
- newProperty("ExternalBoardOptions.mySQLColumnSalt", "");
+ newProperty("DataSource.mySQLColumnSalt", "");
@Comment("Column for storing players emails")
public static final Property MYSQL_COL_EMAIL =
@@ -79,6 +79,10 @@ public final class DatabaseSettings implements SettingsHolder {
public static final Property MYSQL_COL_HASSESSION =
newProperty("DataSource.mySQLColumnHasSession", "hasSession");
+ @Comment("Column for storing a player's TOTP key (for two-factor authentication)")
+ public static final Property MYSQL_COL_TOTP_KEY =
+ newProperty("DataSource.mySQLtotpKey", "totp");
+
@Comment("Column for storing the player's last IP")
public static final Property MYSQL_COL_LAST_IP =
newProperty("DataSource.mySQLColumnIp", "ip");
diff --git a/src/main/java/fr/xephi/authme/settings/properties/ProtectionSettings.java b/src/main/java/fr/xephi/authme/settings/properties/ProtectionSettings.java
index f0a6c2f74..cc715d0ca 100644
--- a/src/main/java/fr/xephi/authme/settings/properties/ProtectionSettings.java
+++ b/src/main/java/fr/xephi/authme/settings/properties/ProtectionSettings.java
@@ -22,7 +22,7 @@ public final class ProtectionSettings implements SettingsHolder {
@Comment({
"Countries allowed to join the server and register. For country codes, see",
- "http://dev.maxmind.com/geoip/legacy/codes/iso3166/",
+ "https://dev.maxmind.com/geoip/legacy/codes/iso3166/",
"PLEASE USE QUOTES!"})
public static final Property> COUNTRIES_WHITELIST =
newListProperty("Protection.countries", "US", "GB");
@@ -55,6 +55,10 @@ public final class ProtectionSettings implements SettingsHolder {
public static final Property ANTIBOT_DELAY =
newProperty("Protection.antiBotDelay", 60);
+ @Comment("Kicks the player that issued a command before the defined time after the join process")
+ public static final Property QUICK_COMMANDS_DENIED_BEFORE_MILLISECONDS =
+ newProperty("Protection.quickCommands.denyCommandsBeforeMilliseconds", 1000);
+
private ProtectionSettings() {
}
diff --git a/src/main/java/fr/xephi/authme/settings/properties/RestrictionSettings.java b/src/main/java/fr/xephi/authme/settings/properties/RestrictionSettings.java
index 492af4e6d..e769dd9b0 100644
--- a/src/main/java/fr/xephi/authme/settings/properties/RestrictionSettings.java
+++ b/src/main/java/fr/xephi/authme/settings/properties/RestrictionSettings.java
@@ -26,7 +26,7 @@ public final class RestrictionSettings implements SettingsHolder {
@Comment("Allowed commands for unauthenticated players")
public static final Property> ALLOW_COMMANDS =
newLowercaseListProperty("settings.restrictions.allowCommands",
- "/login", "/register", "/l", "/reg", "/email", "/captcha");
+ "/login", "/register", "/l", "/reg", "/email", "/captcha", "/2fa", "/totp");
@Comment({
"Max number of allowed registrations per IP",
diff --git a/src/main/java/fr/xephi/authme/task/purge/PurgeExecutor.java b/src/main/java/fr/xephi/authme/task/purge/PurgeExecutor.java
index 04b7f5cc6..36c951ffc 100644
--- a/src/main/java/fr/xephi/authme/task/purge/PurgeExecutor.java
+++ b/src/main/java/fr/xephi/authme/task/purge/PurgeExecutor.java
@@ -49,7 +49,7 @@ public class PurgeExecutor {
* players and names.
*
* @param players the players to purge
- * @param names names to purge
+ * @param names names to purge
*/
public void executePurge(Collection players, Collection names) {
// Purge other data
@@ -61,6 +61,11 @@ public class PurgeExecutor {
purgePermissions(players);
}
+ /**
+ * Purges data from the AntiXray plugin.
+ *
+ * @param cleared the players whose data should be cleared
+ */
synchronized void purgeAntiXray(Collection cleared) {
if (!settings.getProperty(PurgeSettings.REMOVE_ANTI_XRAY_FILE)) {
return;
@@ -95,6 +100,11 @@ public class PurgeExecutor {
ConsoleLogger.info(ChatColor.GOLD + "Deleted " + names.size() + " user accounts");
}
+ /**
+ * Purges data from the LimitedCreative plugin.
+ *
+ * @param cleared the players whose data should be cleared
+ */
synchronized void purgeLimitedCreative(Collection cleared) {
if (!settings.getProperty(PurgeSettings.REMOVE_LIMITED_CREATIVE_INVENTORIES)) {
return;
@@ -191,21 +201,24 @@ public class PurgeExecutor {
ConsoleLogger.info("AutoPurge: Removed " + deletedFiles + " EssentialsFiles");
}
+ /**
+ * Removes permission data (groups a user belongs to) for the given players.
+ *
+ * @param cleared the players to remove data for
+ */
synchronized void purgePermissions(Collection cleared) {
if (!settings.getProperty(PurgeSettings.REMOVE_PERMISSIONS)) {
return;
}
for (OfflinePlayer offlinePlayer : cleared) {
- try {
- permissionsManager.loadUserData(offlinePlayer.getUniqueId());
- } catch (NoSuchMethodError e) {
- permissionsManager.loadUserData(offlinePlayer.getName());
+ if (!permissionsManager.loadUserData(offlinePlayer)) {
+ ConsoleLogger.warning("Unable to purge the permissions of user " + offlinePlayer + "!");
+ continue;
}
permissionsManager.removeAllGroups(offlinePlayer);
}
ConsoleLogger.info("AutoPurge: Removed permissions from " + cleared.size() + " player(s).");
}
-
}
diff --git a/src/main/java/fr/xephi/authme/task/purge/PurgeTask.java b/src/main/java/fr/xephi/authme/task/purge/PurgeTask.java
index 92391af90..686bab86d 100644
--- a/src/main/java/fr/xephi/authme/task/purge/PurgeTask.java
+++ b/src/main/java/fr/xephi/authme/task/purge/PurgeTask.java
@@ -3,6 +3,7 @@ package fr.xephi.authme.task.purge;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.permission.PermissionsManager;
import fr.xephi.authme.permission.PlayerStatePermission;
+import fr.xephi.authme.permission.handlers.PermissionLoadUserException;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.OfflinePlayer;
@@ -73,6 +74,10 @@ class PurgeTask extends BukkitRunnable {
OfflinePlayer offlinePlayer = offlinePlayers[nextPosition];
if (offlinePlayer.getName() != null && toPurge.remove(offlinePlayer.getName().toLowerCase())) {
+ if(!permissionsManager.loadUserData(offlinePlayer)) {
+ ConsoleLogger.warning("Unable to check if the user " + offlinePlayer.getName() + " can be purged!");
+ continue;
+ }
if (!permissionsManager.hasPermissionOffline(offlinePlayer, PlayerStatePermission.BYPASS_PURGE)) {
playerPortion.add(offlinePlayer);
namePortion.add(offlinePlayer.getName());
diff --git a/src/main/java/fr/xephi/authme/util/ExceptionUtils.java b/src/main/java/fr/xephi/authme/util/ExceptionUtils.java
new file mode 100644
index 000000000..fd5ae8852
--- /dev/null
+++ b/src/main/java/fr/xephi/authme/util/ExceptionUtils.java
@@ -0,0 +1,46 @@
+package fr.xephi.authme.util;
+
+import com.google.common.collect.Sets;
+
+import java.util.Set;
+
+/**
+ * Utilities for exceptions.
+ */
+public final class ExceptionUtils {
+
+ private ExceptionUtils() {
+ }
+
+ /**
+ * Returns the first throwable of the given {@code wantedThrowableType} by visiting the provided
+ * throwable and its causes recursively.
+ *
+ * @param wantedThrowableType the throwable type to find
+ * @param throwable the throwable to start with
+ * @param the desired throwable subtype
+ * @return the first throwable found of the given type, or null if none found
+ */
+ public static T findThrowableInCause(Class wantedThrowableType, Throwable throwable) {
+ Set visitedObjects = Sets.newIdentityHashSet();
+ Throwable currentThrowable = throwable;
+ while (currentThrowable != null && !visitedObjects.contains(currentThrowable)) {
+ if (wantedThrowableType.isInstance(currentThrowable)) {
+ return wantedThrowableType.cast(currentThrowable);
+ }
+ visitedObjects.add(currentThrowable);
+ currentThrowable = currentThrowable.getCause();
+ }
+ return null;
+ }
+
+ /**
+ * Format the information from a Throwable as string, retaining the type and its message.
+ *
+ * @param th the throwable to process
+ * @return string with the type of the Throwable and its message, e.g. "[IOException]: Could not open stream"
+ */
+ public static String formatException(Throwable th) {
+ return "[" + th.getClass().getSimpleName() + "]: " + th.getMessage();
+ }
+}
diff --git a/src/main/java/fr/xephi/authme/util/FileUtils.java b/src/main/java/fr/xephi/authme/util/FileUtils.java
index 759f55811..bc1bef41c 100644
--- a/src/main/java/fr/xephi/authme/util/FileUtils.java
+++ b/src/main/java/fr/xephi/authme/util/FileUtils.java
@@ -1,12 +1,14 @@
package fr.xephi.authme.util;
+import com.google.common.io.Files;
import fr.xephi.authme.AuthMe;
import fr.xephi.authme.ConsoleLogger;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
-import java.nio.file.Files;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
import static java.lang.String.format;
@@ -15,6 +17,9 @@ import static java.lang.String.format;
*/
public final class FileUtils {
+ private static final DateTimeFormatter CURRENT_DATE_STRING_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmm");
+
// Utility class
private FileUtils() {
}
@@ -40,7 +45,7 @@ public final class FileUtils {
ConsoleLogger.warning(format("Cannot copy resource '%s' to file '%s': cannot load resource",
resourcePath, destinationFile.getPath()));
} else {
- Files.copy(is, destinationFile.toPath());
+ java.nio.file.Files.copy(is, destinationFile.toPath());
return true;
}
} catch (IOException e) {
@@ -138,4 +143,28 @@ public final class FileUtils {
public static String makePath(String... elements) {
return String.join(File.separator, elements);
}
+
+ /**
+ * Creates a textual representation of the current time (including minutes), e.g. useful for
+ * automatically generated backup files.
+ *
+ * @return string of the current time for use in file names
+ */
+ public static String createCurrentTimeString() {
+ return LocalDateTime.now().format(CURRENT_DATE_STRING_FORMATTER);
+ }
+
+ /**
+ * Returns a path to a new file (which doesn't exist yet) with a timestamp in the name in the same
+ * folder as the given file and containing the given file's filename.
+ *
+ * @param file the file based on which a new file path should be created
+ * @return path to a file suitably named for storing a backup
+ */
+ public static String createBackupFilePath(File file) {
+ String filename = "backup_" + Files.getNameWithoutExtension(file.getName())
+ + "_" + createCurrentTimeString()
+ + "." + Files.getFileExtension(file.getName());
+ return makePath(file.getParent(), filename);
+ }
}
diff --git a/src/main/java/fr/xephi/authme/util/InternetProtocolUtils.java b/src/main/java/fr/xephi/authme/util/InternetProtocolUtils.java
index 548e1e913..039421548 100644
--- a/src/main/java/fr/xephi/authme/util/InternetProtocolUtils.java
+++ b/src/main/java/fr/xephi/authme/util/InternetProtocolUtils.java
@@ -1,16 +1,13 @@
package fr.xephi.authme.util;
-import java.util.regex.Pattern;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
/**
* Utility class about the InternetProtocol
*/
public final class InternetProtocolUtils {
- private static final Pattern LOCAL_ADDRESS_PATTERN =
- Pattern.compile("(^127\\.)|(^(0)?10\\.)|(^172\\.(0)?1[6-9]\\.)|(^172\\.(0)?2[0-9]\\.)"
- + "|(^172\\.(0)?3[0-1]\\.)|(^169\\.254\\.)|(^192\\.168\\.)");
-
// Utility class
private InternetProtocolUtils() {
}
@@ -19,10 +16,57 @@ public final class InternetProtocolUtils {
* Checks if the specified address is a private or loopback address
*
* @param address address to check
- *
- * @return true if the address is a local or loopback address, false otherwise
+ * @return true if the address is a local (site and link) or loopback address, false otherwise
*/
public static boolean isLocalAddress(String address) {
- return LOCAL_ADDRESS_PATTERN.matcher(address).find();
+ try {
+ InetAddress inetAddress = InetAddress.getByName(address);
+
+ // Examples: 127.0.0.1, localhost or [::1]
+ return isLoopbackAddress(address)
+ // Example: 10.0.0.0, 172.16.0.0, 192.168.0.0, fec0::/10 (deprecated)
+ // Ref: https://en.wikipedia.org/wiki/IP_address#Private_addresses
+ || inetAddress.isSiteLocalAddress()
+ // Example: 169.254.0.0/16, fe80::/10
+ // Ref: https://en.wikipedia.org/wiki/IP_address#Address_autoconfiguration
+ || inetAddress.isLinkLocalAddress()
+ // non deprecated unique site-local that java doesn't check yet -> fc00::/7
+ || isIPv6UniqueSiteLocal(inetAddress);
+ } catch (UnknownHostException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Checks if the specified address is a loopback address. This can be one of the following:
+ *
+ * - 127.0.0.1
+ * - localhost
+ * - [::1]
+ *
+ *
+ * @param address address to check
+ * @return true if the address is a loopback one
+ */
+ public static boolean isLoopbackAddress(String address) {
+ try {
+ InetAddress inetAddress = InetAddress.getByName(address);
+ return inetAddress.isLoopbackAddress();
+ } catch (UnknownHostException e) {
+ return false;
+ }
+ }
+
+ private static boolean isLoopbackAddress(InetAddress address) {
+ return address.isLoopbackAddress();
+ }
+
+ private static boolean isIPv6UniqueSiteLocal(InetAddress address) {
+ // ref: https://en.wikipedia.org/wiki/Unique_local_address
+
+ // currently undefined but could be used in the near future fc00::/8
+ return (address.getAddress()[0] & 0xFF) == 0xFC
+ // in use for unique site-local fd00::/8
+ || (address.getAddress()[0] & 0xFF) == 0xFD;
}
}
diff --git a/src/main/java/fr/xephi/authme/util/StringUtils.java b/src/main/java/fr/xephi/authme/util/StringUtils.java
index 1f200c0f0..5c8613005 100644
--- a/src/main/java/fr/xephi/authme/util/StringUtils.java
+++ b/src/main/java/fr/xephi/authme/util/StringUtils.java
@@ -66,17 +66,6 @@ public final class StringUtils {
return str == null || str.trim().isEmpty();
}
- /**
- * Format the information from a Throwable as string, retaining the type and its message.
- *
- * @param th The throwable to process
- *
- * @return String with the type of the Throwable and its message, e.g. "[IOException]: Could not open stream"
- */
- public static String formatException(Throwable th) {
- return "[" + th.getClass().getSimpleName() + "]: " + th.getMessage();
- }
-
/**
* Check that the given needle is in the middle of the haystack, i.e. that the haystack
* contains the needle and that it is not at the very start or end.
diff --git a/src/main/resources/messages/messages_bg.yml b/src/main/resources/messages/messages_bg.yml
index d43639d55..4687a8b99 100644
--- a/src/main/resources/messages/messages_bg.yml
+++ b/src/main/resources/messages/messages_bg.yml
@@ -1,118 +1,156 @@
+# List of global tags:
+# %nl% - Goes to new line.
+# %username% - Replaces the username of the player receiving the message.
+# %displayname% - Replaces the nickname (and colors) of the player receiving the message.
+
# Registration
-reg_msg: '&3Моля регистрирайте се с: /register парола парола'
-usage_reg: '&cКоманда: /register парола парола'
-reg_only: '&4Само регистрирани потребители могат да влизат в сървъра! Моля посетете http://example.com, за да се регистрирате!'
-kicked_admin_registered: 'Ти беше регистриран от администратора, моля влезте отново'
-registered: '&2Успешна регистрация!'
-reg_disabled: '&cРегистрациите са изключени!'
-user_regged: '&cПотребителското име е заетo!'
+registration:
+ disabled: '&cРегистрациите са изключени!'
+ name_taken: '&cПотребителското име е заетo!'
+ register_request: '&3Моля регистрирайте се с: /register парола парола'
+ command_usage: '&cКоманда: /register парола парола'
+ reg_only: '&4Само регистрирани потребители могат да влизат в сървъра! Моля посетете http://example.com, за да се регистрирате!'
+ success: '&2Успешна регистрация!'
+ kicked_admin_registered: 'Ти беше регистриран от администратора, моля влезте отново'
# Password errors on registration
-password_error: '&cПаролите не съвпадат, провете ги отново!'
-password_error_nick: '&cНе можеш да използваш потребителското си име за парола, моля изберете друга парола.'
-password_error_unsafe: '&cИзбраната парола не е безопасна, моля изберете друга парола.'
-password_error_chars: '&4Паролата съдържа непозволени символи. Позволени символи: REG_EX'
-pass_len: '&cПаролата е твърде къса или прекалено дълга! Моля опитайте с друга парола.'
+password:
+ match_error: '&cПаролите не съвпадат, провете ги отново!'
+ name_in_password: '&cНе можеш да използваш потребителското си име за парола, моля изберете друга парола.'
+ unsafe_password: '&cИзбраната парола не е безопасна, моля изберете друга парола.'
+ forbidden_characters: '&4Паролата съдържа непозволени символи. Позволени символи: %valid_chars'
+ wrong_length: '&cПаролата е твърде къса или прекалено дълга! Моля опитайте с друга парола.'
# Login
-usage_log: '&cКоманда: /login парола'
-wrong_pwd: '&cГрешна парола!'
-login: '&2Успешен вход!'
-login_msg: '&cМоля влезте с: /login парола'
-timeout: '&4Времето за вход изтече, беше кикнат от сървъра. Моля опитайте отново!'
+login:
+ command_usage: '&cКоманда: /login парола'
+ wrong_password: '&cГрешна парола!'
+ success: '&2Успешен вход!'
+ login_request: '&cМоля влезте с: /login парола'
+ timeout_error: '&4Времето за вход изтече, беше кикнат от сървъра. Моля опитайте отново!'
# Errors
-unknown_user: '&cПотребителското име не е регистрирано!'
-denied_command: '&cЗа да използваш тази команда трябва да си си влезнал в акаунта!'
-denied_chat: '&cЗа да пишеш в чата трябва даи сиси влезнал в акаунта!'
-not_logged_in: '&cНе си влязъл!'
-tempban_max_logins: '&cТи беше баннат временно, понеже си сгрешил паролата прекалено много пъти.'
-max_reg: '&cТи си достигнал максималният брой регистрации (%reg_count/%max_acc %reg_names)!'
-no_perm: '&4Нямаш нужните права за това действие!'
-error: '&4Получи се неочаквана грешка, моля свържете се с администратора!'
-kick_forvip: '&3VIP потребител влезе докато сървъра беше пълен, ти беше изгонен!'
+error:
+ denied_command: '&cЗа да използваш тази команда трябва да си си влезнал в акаунта!'
+ denied_chat: '&cЗа да пишеш в чата трябва даи сиси влезнал в акаунта!'
+ unregistered_user: '&cПотребителското име не е регистрирано!'
+ not_logged_in: '&cНе си влязъл!'
+ no_permission: '&4Нямаш нужните права за това действие!'
+ unexpected_error: '&4Получи се неочаквана грешка, моля свържете се с администратора!'
+ max_registration: '&cТи си достигнал максималният брой регистрации (%reg_count/%max_acc %reg_names)!'
+ logged_in: '&cВече си вписан!'
+ kick_for_vip: '&3VIP потребител влезе докато сървъра беше пълен, ти беше изгонен!'
+ tempban_max_logins: '&cТи беше баннат временно, понеже си сгрешил паролата прекалено много пъти.'
# AntiBot
-kick_antibot: 'Защитата от ботове е включена! Трябва да изчакаш няколко минути преди да влезеш в сървъра.'
-antibot_auto_enabled: '&4Защитата за ботове е включена заради потенциална атака!'
-antibot_auto_disabled: '&2Защитата за ботове ще се изключи след %m минута/и!'
+antibot:
+ kick_antibot: 'Защитата от ботове е включена! Трябва да изчакаш няколко минути преди да влезеш в сървъра.'
+ auto_enabled: '&4Защитата за ботове е включена заради потенциална атака!'
+ auto_disabled: '&2Защитата за ботове ще се изключи след %m минута/и!'
+
+# Unregister
+unregister:
+ success: '&cРегистрацията е премахната успешно!'
+ command_usage: '&cКоманда: /unregister парола'
# Other messages
-unregistered: '&cРегистрацията е премахната успешно!'
-accounts_owned_self: 'Претежаваш %count акаунт/а:'
-accounts_owned_other: 'Потребителят %name има %count акаунт/а:'
-two_factor_create: '&2Кода е %code. Можеш да го провериш оттука: %url'
-recovery_code_sent: 'Възстановяващият код беше изпратен на твоят email адрес.'
-# TODO: Missing tags %count
-recovery_code_incorrect: 'Възстановяващият код е неправилен! Използвайте: /email recovery имейл, за да генерирате нов'
-# TODO recovery_tries_exceeded: 'You have exceeded the maximum number attempts to enter the recovery code. Use "/email recovery [email]" to generate a new one.'
-# TODO recovery_code_correct: 'Recovery code entered correctly!'
-# TODO recovery_change_password: 'Please use the command /email setpassword to change your password immediately.'
-vb_nonActiv: '&cТвоят акаунт все още не е актириван, моля провете своят email адрес!'
-usage_unreg: '&cКоманда: /unregister парола'
-pwd_changed: '&2Паротала е променена успешно!'
-logged_in: '&cВече си вписан!'
-logout: '&2Излязохте успешно!'
-reload: '&2Конфигурацията и база данните бяха презаредени правилно!'
-usage_changepassword: '&cКоманда: /changepassword Стара-Парола Нова-Парола'
+misc:
+ account_not_activated: '&cТвоят акаунт все още не е актириван, моля провете своят email адрес!'
+ password_changed: '&2Паротала е променена успешно!'
+ logout: '&2Излязохте успешно!'
+ reload: '&2Конфигурацията и база данните бяха презаредени правилно!'
+ usage_change_password: '&cКоманда: /changepassword Стара-Парола Нова-Парола'
+ accounts_owned_self: 'Претежаваш %count акаунт/а:'
+ accounts_owned_other: 'Потребителят %name има %count акаунт/а:'
# Session messages
-invalid_session: '&cТвоят IP се е променил и сесията беше прекратена.'
-valid_session: '&2Сесията е продължена.'
+session:
+ valid_session: '&2Сесията е продължена.'
+ invalid_session: '&cТвоят IP се е променил и сесията беше прекратена.'
# Error messages when joining
-name_len: '&4Потребителското име е прекалено късо или дълга. Моля опитайте с друго потребителско име!'
-regex: '&4Потребителското име съдържа забранени знаци. Позволени знаци: REG_EX'
-country_banned: '&4Твоята държава е забранена в този сървър!'
-not_owner_error: 'Ти не си собственика на този акаунт. Моля избери друго потребителско име!'
-kick_fullserver: '&4Сървъра е пълен, моля опитайте отново!'
-same_nick: '&4Вече има потребител, който играете в сървъра със същото потребителско име!'
-invalid_name_case: 'Трябва да влезеш с %valid, а не с %invalid.'
-same_ip_online: 'Вече има потребител със същото IP в сървъра!'
+on_join_validation:
+ same_ip_online: 'Вече има потребител със същото IP в сървъра!'
+ same_nick_online: '&4Вече има потребител, който играете в сървъра със същото потребителско име!'
+ name_length: '&4Потребителското име е прекалено късо или дълга. Моля опитайте с друго потребителско име!'
+ characters_in_name: '&4Потребителското име съдържа забранени знаци. Позволени знаци: %valid_chars'
+ kick_full_server: '&4Сървъра е пълен, моля опитайте отново!'
+ country_banned: '&4Твоята държава е забранена в този сървър!'
+ not_owner_error: 'Ти не си собственика на този акаунт. Моля избери друго потребителско име!'
+ invalid_name_case: 'Трябва да влезеш с %valid, а не с %invalid.'
+ # TODO quick_command: 'You used a command too fast! Please, join the server again and wait more before using any command.'
# Email
-usage_email_add: '&cКоманда: /email add имейл имейл'
-usage_email_change: '&cКоманда: /email change Стар-Имейл Нов-Имейл'
-usage_email_recovery: '&cКоманда: /email recovery имейл'
-new_email_invalid: '&cНовият имейл е грешен, опитайте отново!'
-old_email_invalid: '&cСтарият имейл е грешен, опитайте отново!'
-email_invalid: '&cИмейла е невалиден, опитайте с друг!'
-email_added: '&2Имейл адреса е добавен!'
-email_confirm: '&cМоля потвърди своя имейл адрес!'
-email_changed: '&2Имейл адреса е сменен!'
-email_send: '&2Възстановяващият имейл е изпратен успешно. Моля провете пощата си!'
-email_show: '&2Твоят имейл адрес е: &f%email'
-incomplete_email_settings: 'Грешка: Не всички настройки са написани за изпращане на имейл адрес. Моля свържете се с администратора!'
-email_already_used: '&4Имейл адреса вече се използва, опитайте с друг.'
-email_send_failure: 'Съобщението не беше изпратено. Моля свържете се с администратора.'
-show_no_email: '&2Няма добавен имейл адрес към акаунта.'
-add_email: '&3Моля добавете имейл адрес към своят акаунт: /email add имейл имейл'
-recovery_email: '&3Забравена парола? Използвайте: /email recovery имейл'
-# TODO change_password_expired: 'You cannot change your password using this command anymore.'
-email_cooldown_error: '&cВече е бил изпратен имейл адрес. Трябва а изчакаш %time преди да пратиш нов.'
+email:
+ add_email_request: '&3Моля добавете имейл адрес към своят акаунт: /email add имейл имейл'
+ usage_email_add: '&cКоманда: /email add имейл имейл'
+ usage_email_change: '&cКоманда: /email change Стар-Имейл Нов-Имейл'
+ new_email_invalid: '&cНовият имейл е грешен, опитайте отново!'
+ old_email_invalid: '&cСтарият имейл е грешен, опитайте отново!'
+ invalid: '&cИмейла е невалиден, опитайте с друг!'
+ added: '&2Имейл адреса е добавен!'
+ # TODO add_not_allowed: '&cAdding email was not allowed'
+ request_confirmation: '&cМоля потвърди своя имейл адрес!'
+ changed: '&2Имейл адреса е сменен!'
+ # TODO change_not_allowed: '&cChanging email was not allowed'
+ email_show: '&2Твоят имейл адрес е: &f%email'
+ no_email_for_account: '&2Няма добавен имейл адрес към акаунта.'
+ already_used: '&4Имейл адреса вече се използва, опитайте с друг.'
+ incomplete_settings: 'Грешка: Не всички настройки са написани за изпращане на имейл адрес. Моля свържете се с администратора!'
+ send_failure: 'Съобщението не беше изпратено. Моля свържете се с администратора.'
+ # TODO change_password_expired: 'You cannot change your password using this command anymore.'
+ email_cooldown_error: '&cВече е бил изпратен имейл адрес. Трябва а изчакаш %time преди да пратиш нов.'
+
+# Password recovery by email
+recovery:
+ forgot_password_hint: '&3Забравена парола? Използвайте: /email recovery имейл'
+ command_usage: '&cКоманда: /email recovery имейл'
+ email_sent: '&2Възстановяващият имейл е изпратен успешно. Моля провете пощата си!'
+ code:
+ code_sent: 'Възстановяващият код беше изпратен на твоят email адрес.'
+ incorrect: 'Възстановяващият код е неправилен! Използвайте: /email recovery имейл, за да генерирате нов'
+ # TODO tries_exceeded: 'You have exceeded the maximum number attempts to enter the recovery code. Use "/email recovery [email]" to generate a new one.'
+ # TODO correct: 'Recovery code entered correctly!'
+ # TODO change_password: 'Please use the command /email setpassword to change your password immediately.'
# Captcha
-usage_captcha: '&3Моля въведе цифрите/буквите от капчата: /captcha '
-wrong_captcha: '&cКода е грешен, използвайте: "/captcha THE_CAPTCHA" в чата!'
-valid_captcha: '&2Кода е валиден!'
-# TODO captcha_for_registration: 'To register you have to solve a captcha first, please use the command: /captcha '
-# TODO register_captcha_valid: '&2Valid captcha! You may now register with /register'
+captcha:
+ usage_captcha: '&3Моля въведе цифрите/буквите от капчата: /captcha %captcha_code'
+ wrong_captcha: '&cКода е грешен, използвайте: "/captcha %captcha_code" в чата!'
+ valid_captcha: '&2Кода е валиден!'
+ # TODO captcha_for_registration: 'To register you have to solve a captcha first, please use the command: /captcha %captcha_code'
+ # TODO register_captcha_valid: '&2Valid captcha! You may now register with /register'
# Verification code
-# TODO verification_code_required: '&3This command is sensitive and requires an email verification! Check your inbox and follow the email''s instructions.'
-# TODO usage_verification_code: '&cUsage: /verification '
-# TODO incorrect_verification_code: '&cIncorrect code, please type "/verification " into the chat, using the code you received by email'
-# TODO verification_code_verified: '&2Your identity has been verified! You can now execute all commands within the current session!'
-# TODO verification_code_already_verified: '&2You can already execute every sensitive command within the current session!'
-# TODO verification_code_expired: '&3Your code has expired! Execute another sensitive command to get a new code!'
-# TODO verification_code_email_needed: '&3To verify your identity you need to link an email address with your account!!'
+verification:
+ # TODO code_required: '&3This command is sensitive and requires an email verification! Check your inbox and follow the email''s instructions.'
+ # TODO command_usage: '&cUsage: /verification '
+ # TODO incorrect_code: '&cIncorrect code, please type "/verification " into the chat, using the code you received by email'
+ # TODO success: '&2Your identity has been verified! You can now execute all commands within the current session!'
+ # TODO already_verified: '&2You can already execute every sensitive command within the current session!'
+ # TODO code_expired: '&3Your code has expired! Execute another sensitive command to get a new code!'
+ # TODO email_needed: '&3To verify your identity you need to link an email address with your account!!'
# Time units
-second: 'секунда'
-seconds: 'секунди'
-minute: 'минута'
-minutes: 'минути'
-hour: 'час'
-hours: 'часа'
-day: 'ден'
-days: 'дена'
+time:
+ second: 'секунда'
+ seconds: 'секунди'
+ minute: 'минута'
+ minutes: 'минути'
+ hour: 'час'
+ hours: 'часа'
+ day: 'ден'
+ days: 'дена'
+
+# Two-factor authentication
+two_factor:
+ code_created: '&2Кода е %code. Можеш да го провериш оттука: %url'
+ # TODO confirmation_required: 'Please confirm your code with /2fa confirm '
+ # TODO code_required: 'Please submit your two-factor authentication code with /2fa code '
+ # TODO already_enabled: 'Two-factor authentication is already enabled for your account!'
+ # TODO enable_error_no_code: 'No 2fa key has been generated for you or it has expired. Please run /2fa add'
+ # TODO enable_success: 'Successfully enabled two-factor authentication for your account'
+ # TODO enable_error_wrong_code: 'Wrong code or code has expired. Please run /2fa add'
+ # TODO not_enabled_error: 'Two-factor authentication is not enabled for your account. Run /2fa add'
+ # TODO removed_success: 'Successfully removed two-factor auth from your account'
+ # TODO invalid_code: 'Invalid code!'
diff --git a/src/main/resources/messages/messages_br.yml b/src/main/resources/messages/messages_br.yml
index a0200415e..40c58fc8f 100644
--- a/src/main/resources/messages/messages_br.yml
+++ b/src/main/resources/messages/messages_br.yml
@@ -1,122 +1,159 @@
#Tradução pt/br Authme Reloaded
#Feito por GabrielDev(DeathRush) e Frani (PotterCraft_)
# http://gamersboard.com.br/ | www.magitechserver.com
+# List of global tags:
+# %nl% - Goes to new line.
+# %username% - Replaces the username of the player receiving the message.
+# %displayname% - Replaces the nickname (and colors) of the player receiving the message.
# Registration
-reg_msg: '&3Por favor, registre-se com o comando "/register "'
-usage_reg: '&cUse: /register '
-reg_only: '&4Somente usuários registrados podem entrar no servidor! Por favor visite www.seusite.com para se registrar!'
-kicked_admin_registered: 'Um administrador registrou você, por favor faça login novamente'
-registered: '&2Registrado com êxito!'
-reg_disabled: '&cO registro está desativado nesse servidor!'
-user_regged: '&cVocê já registrou este nome de usuário!'
+registration:
+ disabled: '&cO registro está desativado nesse servidor!'
+ name_taken: '&cVocê já registrou este nome de usuário!'
+ register_request: '&3Por favor, registre-se com o comando "/register "'
+ command_usage: '&cUse: /register '
+ reg_only: '&4Somente usuários registrados podem entrar no servidor! Por favor visite www.seusite.com para se registrar!'
+ success: '&2Registrado com êxito!'
+ kicked_admin_registered: 'Um administrador registrou você, por favor faça login novamente'
# Password errors on registration
-password_error: '&cAs senhas não coincidem, verifique novamente!'
-password_error_nick: '&Você não pode usar o seu nome como senha, por favor, escolha outra senha...'
-password_error_unsafe: '&cA senha escolhida não é segura, por favor, escolha outra...'
-password_error_chars: '&Sua senha contém caracteres ilegais. caracteres permitidos: REG_EX'
-pass_len: '&cSua senha é muito curta ou muito longa! Por favor, tente com outra!'
+password:
+ match_error: '&cAs senhas não coincidem, verifique novamente!'
+ name_in_password: '&Você não pode usar o seu nome como senha, por favor, escolha outra senha...'
+ unsafe_password: '&cA senha escolhida não é segura, por favor, escolha outra...'
+ forbidden_characters: '&Sua senha contém caracteres ilegais. caracteres permitidos: %valid_chars'
+ wrong_length: '&cSua senha é muito curta ou muito longa! Por favor, tente com outra!'
# Login
-usage_log: '&cUse: /login '
-wrong_pwd: '&cSenha incorreta!'
-login: '&2Login realizado com sucesso!'
-login_msg: '&cPor favor, faça o login com o comando "/login "'
-timeout: '&4Tempo limite de sessão excedido, você foi expulso do servidor, por favor, tente novamente!'
+login:
+ command_usage: '&cUse: /login '
+ wrong_password: '&cSenha incorreta!'
+ success: '&2Login realizado com sucesso!'
+ login_request: '&cPor favor, faça o login com o comando "/login "'
+ timeout_error: '&4Tempo limite de sessão excedido, você foi expulso do servidor, por favor, tente novamente!'
# Errors
-unknown_user: '&cEste usuário não está registrado!'
-denied_command: '&cPara utilizar este comando é necessário estar logado!'
-denied_chat: '&cPara utilizar o chat, você deve estar logado!'
-not_logged_in: '&cVocê não está logado!'
-tempban_max_logins: '&cVocê foi temporariamente banido por tentar logar muitas vezes.'
-max_reg: '&cVocê excedeu o número máximo de inscrições (%reg_count/%max_acc %reg_names) do seu IP!'
-no_perm: '&4Você não tem permissão para executar esta ação!'
-error: '&4Ocorreu um erro inesperado, por favor contacte um administrador!'
-kick_forvip: '&3Um jogador VIP juntou-se ao servidor enquanto ele estava cheio!'
+error:
+ denied_command: '&cPara utilizar este comando é necessário estar logado!'
+ denied_chat: '&cPara utilizar o chat, você deve estar logado!'
+ unregistered_user: '&cEste usuário não está registrado!'
+ not_logged_in: '&cVocê não está logado!'
+ no_permission: '&4Você não tem permissão para executar esta ação!'
+ unexpected_error: '&4Ocorreu um erro inesperado, por favor contacte um administrador!'
+ max_registration: '&cVocê excedeu o número máximo de inscrições (%reg_count/%max_acc %reg_names) do seu IP!'
+ logged_in: '&cVocê já está logado!'
+ kick_for_vip: '&3Um jogador VIP juntou-se ao servidor enquanto ele estava cheio!'
+ tempban_max_logins: '&cVocê foi temporariamente banido por tentar logar muitas vezes.'
# AntiBot
-kick_antibot: 'O AntiBot está ativo, espere alguns minutos antes de entrar no servidor!'
-antibot_auto_enabled: '&4O AntiBot foi ativado devido ao grande número de conexões!'
-antibot_auto_disabled: '&2Desativando o AntiBot passados %m minutos!'
+antibot:
+ kick_antibot: 'O AntiBot está ativo, espere alguns minutos antes de entrar no servidor!'
+ auto_enabled: '&4O AntiBot foi ativado devido ao grande número de conexões!'
+ auto_disabled: '&2Desativando o AntiBot passados %m minutos!'
+
+# Unregister
+unregister:
+ success: '&cConta deletada!'
+ command_usage: '&cUse: /unregister '
# Other messages
-unregistered: '&cConta deletada!'
-accounts_owned_self: 'Você tem %count contas:'
-accounts_owned_other: 'O jogador %name tem %count contas:'
-two_factor_create: '&2O seu código secreto é %code. Você pode verificá-lo a partir daqui %url'
-recovery_code_sent: 'Um código de recuperação para redefinir sua senha foi enviada para o seu e-mail.'
-# TODO: Missing tags %count
-recovery_code_incorrect: 'O código de recuperação esta incorreto! Use /email recovery [email] para gerar um novo!'
-recovery_tries_exceeded: 'Você excedeu o limite de tentativas de usar o código de recuperação! Use "/email recovery [email]" para gerar um novo.'
-recovery_code_correct: 'Código de recuperação aceito!'
-recovery_change_password: 'Por favor, use o comando /email setpassword para alterar sua senha imediatamente!'
-vb_nonActiv: '&cA sua conta ainda não está ativada, por favor, verifique seus e-mails!'
-usage_unreg: '&cUse: /unregister '
-pwd_changed: '&2Senha alterada com sucesso!'
-logged_in: '&cVocê já está logado!'
-logout: '&2Desconectado com sucesso!'
-reload: '&2Configuração e o banco de dados foram recarregados corretamente!'
-usage_changepassword: '&cUse: /changepassword '
+misc:
+ account_not_activated: '&cA sua conta ainda não está ativada, por favor, verifique seus e-mails!'
+ password_changed: '&2Senha alterada com sucesso!'
+ logout: '&2Desconectado com sucesso!'
+ reload: '&2Configuração e o banco de dados foram recarregados corretamente!'
+ usage_change_password: '&cUse: /changepassword '
+ accounts_owned_self: 'Você tem %count contas:'
+ accounts_owned_other: 'O jogador %name tem %count contas:'
# Session messages
-invalid_session: '&O seu IP foi alterado e sua sessão expirou!'
-valid_session: '&2Você deslogou recentemente, então sua sessão foi resumida!'
+session:
+ valid_session: '&2Você deslogou recentemente, então sua sessão foi resumida!'
+ invalid_session: '&O seu IP foi alterado e sua sessão expirou!'
# Error messages when joining
-name_len: '&4Seu nome de usuário ou é muito curto ou muito longo!'
-regex: '&4Seu nome de usuário contém caracteres inválidos. Caracteres permitidos: REG_EX'
-country_banned: '&4O seu país está banido neste servidor!'
-not_owner_error: 'Você não é o proprietário da conta. Por favor, escolha outro nome!'
-kick_fullserver: '&4O servidor está cheio, tente novamente mais tarde!'
-same_nick: '&4O mesmo nome de usuário já está jogando no servidor!'
-invalid_name_case: 'Você deve se juntar usando nome de usuário %valid, não %invalid.'
-same_ip_online: 'Um jogador com o mesmo IP já está no servidor!'
+on_join_validation:
+ same_ip_online: 'Um jogador com o mesmo IP já está no servidor!'
+ same_nick_online: '&4O mesmo nome de usuário já está jogando no servidor!'
+ name_length: '&4Seu nome de usuário ou é muito curto ou muito longo!'
+ characters_in_name: '&4Seu nome de usuário contém caracteres inválidos. Caracteres permitidos: %valid_chars'
+ kick_full_server: '&4O servidor está cheio, tente novamente mais tarde!'
+ country_banned: '&4O seu país está banido neste servidor!'
+ not_owner_error: 'Você não é o proprietário da conta. Por favor, escolha outro nome!'
+ invalid_name_case: 'Você deve se juntar usando nome de usuário %valid, não %invalid.'
+ # TODO quick_command: 'You used a command too fast! Please, join the server again and wait more before using any command.'
# Email
-usage_email_add: '&cUse: /email add '
-usage_email_change: '&cUse: /email change '
-usage_email_recovery: '&cUse: /email recovery '
-new_email_invalid: '&cE-mail novo inválido, tente novamente!'
-old_email_invalid: '&cE-mail velho inválido, tente novamente!'
-email_invalid: '&E-mail inválido, tente novamente!'
-email_added: '&2Email adicionado com sucesso à sua conta!'
-email_confirm: '&cPor favor confirme seu endereço de email!'
-email_changed: '&2Troca de email com sucesso.!'
-email_send: '&2Recuperação de email enviada com sucesso! Por favor, verifique sua caixa de entrada de e-mail!'
-email_show: '&2O seu endereço de e-mail atual é: &f%email'
-incomplete_email_settings: 'Erro: Nem todas as configurações necessárias estão definidas para o envio de e-mails. Entre em contato com um administrador.'
-email_already_used: '&4O endereço de e-mail já está sendo usado'
-email_send_failure: '&cO e-mail não pôde ser enviado, reporte isso a um administrador!'
-show_no_email: '&2Você atualmente não têm endereço de e-mail associado a esta conta.'
-add_email: '&3Por favor, adicione seu e-mail para a sua conta com o comando "/email add