Email verification code (#1382)

* Base verification code implementation, must define command, manager, events
* VerificationManager, verification command and messages, handled some sensible commands, configuration values
* Improved manager and sensible commands trigger
* Updated messages
* Updated verification code manager, fixed tests
* Switched to a permission based command
* Verification manager and command improved and added tests
* Edited messages
This commit is contained in:
HexelDev
2017-10-28 12:23:14 +02:00
committed by ljacqu
parent 8422581a82
commit ba65633182
58 changed files with 1227 additions and 67 deletions
@@ -40,6 +40,7 @@ import fr.xephi.authme.command.executable.login.LoginCommand;
import fr.xephi.authme.command.executable.logout.LogoutCommand;
import fr.xephi.authme.command.executable.register.RegisterCommand;
import fr.xephi.authme.command.executable.unregister.UnregisterCommand;
import fr.xephi.authme.command.executable.verification.VerificationCommand;
import fr.xephi.authme.permission.AdminPermission;
import fr.xephi.authme.permission.DebugSectionPermissions;
import fr.xephi.authme.permission.PlayerPermission;
@@ -136,13 +137,24 @@ public class CommandInitializer {
CommandDescription captchaBase = CommandDescription.builder()
.parent(null)
.labels("captcha")
.description("Captcha Command")
.description("Captcha command")
.detailedDescription("Captcha command for AuthMeReloaded.")
.withArgument("captcha", "The Captcha", false)
.permission(PlayerPermission.CAPTCHA)
.executableCommand(CaptchaCommand.class)
.register();
// Register the base verification code command
CommandDescription verificationBase = CommandDescription.builder()
.parent(null)
.labels("verification")
.description("Verification command")
.detailedDescription("Command to complete the verification process for AuthMeReloaded.")
.withArgument("code", "The code", false)
.permission(PlayerPermission.VERIFICATION_CODE)
.executableCommand(VerificationCommand.class)
.register();
List<CommandDescription> baseCommands = ImmutableList.of(
authMeBase,
emailBase,
@@ -151,7 +163,8 @@ public class CommandInitializer {
registerBase,
unregisterBase,
changePasswordBase,
captchaBase);
captchaBase,
verificationBase);
setHelpOnAllBases(baseCommands);
commands = baseCommands;
@@ -1,6 +1,7 @@
package fr.xephi.authme.command.executable.changepassword;
import fr.xephi.authme.command.PlayerCommand;
import fr.xephi.authme.data.VerificationCodeManager;
import fr.xephi.authme.data.auth.PlayerCache;
import fr.xephi.authme.message.MessageKey;
import fr.xephi.authme.process.Management;
@@ -29,17 +30,28 @@ public class ChangePasswordCommand extends PlayerCommand {
@Inject
private Management management;
@Inject
private VerificationCodeManager codeManager;
@Override
public void runCommand(Player player, List<String> arguments) {
String oldPassword = arguments.get(0);
String newPassword = arguments.get(1);
String name = player.getName().toLowerCase();
if (!playerCache.isAuthenticated(name)) {
commonService.send(player, MessageKey.NOT_LOGGED_IN);
return;
}
// Check if the user has been verified or not
if (codeManager.isVerificationRequired(player)) {
codeManager.codeExistOrGenerateNew(name);
commonService.send(player, MessageKey.VERIFICATION_CODE_REQUIRED);
return;
}
String oldPassword = arguments.get(0);
String newPassword = arguments.get(1);
// Make sure the password is allowed
ValidationResult passwordValidation = validationService.validatePassword(newPassword, name);
if (passwordValidation.hasError()) {
@@ -1,8 +1,10 @@
package fr.xephi.authme.command.executable.email;
import fr.xephi.authme.command.PlayerCommand;
import fr.xephi.authme.data.VerificationCodeManager;
import fr.xephi.authme.message.MessageKey;
import fr.xephi.authme.process.Management;
import fr.xephi.authme.service.CommonService;
import org.bukkit.entity.Player;
import javax.inject.Inject;
@@ -16,11 +18,24 @@ public class ChangeEmailCommand extends PlayerCommand {
@Inject
private Management management;
@Inject
private CommonService commonService;
@Inject
private VerificationCodeManager codeManager;
@Override
public void runCommand(Player player, List<String> arguments) {
final String playerName = player.getName();
// Check if the user has been verified or not
if (codeManager.isVerificationRequired(player)) {
codeManager.codeExistOrGenerateNew(playerName);
commonService.send(player, MessageKey.VERIFICATION_CODE_REQUIRED);
return;
}
String playerMailOld = arguments.get(0);
String playerMailNew = arguments.get(1);
management.performChangeEmail(player, playerMailOld, playerMailNew);
}
@@ -1,6 +1,7 @@
package fr.xephi.authme.command.executable.unregister;
import fr.xephi.authme.command.PlayerCommand;
import fr.xephi.authme.data.VerificationCodeManager;
import fr.xephi.authme.data.auth.PlayerCache;
import fr.xephi.authme.message.MessageKey;
import fr.xephi.authme.process.Management;
@@ -24,6 +25,9 @@ public class UnregisterCommand extends PlayerCommand {
@Inject
private PlayerCache playerCache;
@Inject
private VerificationCodeManager codeManager;
@Override
public void runCommand(Player player, List<String> arguments) {
String playerPass = arguments.get(0);
@@ -35,6 +39,13 @@ public class UnregisterCommand extends PlayerCommand {
return;
}
// Check if the user has been verified or not
if (codeManager.isVerificationRequired(player)) {
codeManager.codeExistOrGenerateNew(playerName);
commonService.send(player, MessageKey.VERIFICATION_CODE_REQUIRED);
return;
}
// Unregister the player
management.performUnregister(player, playerPass);
}
@@ -0,0 +1,58 @@
package fr.xephi.authme.command.executable.verification;
import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.command.PlayerCommand;
import fr.xephi.authme.data.VerificationCodeManager;
import fr.xephi.authme.message.MessageKey;
import fr.xephi.authme.service.CommonService;
import org.bukkit.entity.Player;
import javax.inject.Inject;
import java.util.List;
/**
* Used to complete the email verification process.
*/
public class VerificationCommand extends PlayerCommand {
@Inject
private CommonService commonService;
@Inject
private VerificationCodeManager codeManager;
@Override
public void runCommand(Player player, List<String> arguments) {
final String playerName = player.getName();
if (!codeManager.canSendMail()) {
ConsoleLogger.warning("Mail API is not set");
commonService.send(player, MessageKey.INCOMPLETE_EMAIL_SETTINGS);
return;
}
if (codeManager.isVerificationRequired(player)) {
if (codeManager.isCodeRequired(playerName)) {
if (codeManager.checkCode(playerName, arguments.get(0))) {
commonService.send(player, MessageKey.VERIFICATION_CODE_VERIFIED);
} else {
commonService.send(player, MessageKey.INCORRECT_VERIFICATION_CODE);
}
} else {
commonService.send(player, MessageKey.VERIFICATION_CODE_EXPIRED);
}
} else {
if (codeManager.hasEmail(playerName)) {
commonService.send(player, MessageKey.VERIFICATION_CODE_ALREADY_VERIFIED);
} else {
commonService.send(player, MessageKey.VERIFICATION_CODE_EMAIL_NEEDED);
commonService.send(player, MessageKey.ADD_EMAIL_MESSAGE);
}
}
}
@Override
public MessageKey getArgumentsMismatchMessage() {
return MessageKey.USAGE_VERIFICATION_CODE;
}
}
@@ -0,0 +1,189 @@
package fr.xephi.authme.data;
import fr.xephi.authme.datasource.DataSource;
import fr.xephi.authme.datasource.DataSourceResult;
import fr.xephi.authme.initialization.HasCleanup;
import fr.xephi.authme.initialization.SettingsDependent;
import fr.xephi.authme.mail.EmailService;
import fr.xephi.authme.permission.PermissionsManager;
import fr.xephi.authme.permission.PlayerPermission;
import fr.xephi.authme.settings.Settings;
import fr.xephi.authme.settings.properties.SecuritySettings;
import fr.xephi.authme.util.RandomStringUtils;
import fr.xephi.authme.util.Utils;
import fr.xephi.authme.util.expiring.ExpiringMap;
import org.bukkit.entity.Player;
import javax.inject.Inject;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
public class VerificationCodeManager implements SettingsDependent, HasCleanup {
private final EmailService emailService;
private final DataSource dataSource;
private final PermissionsManager permissionsManager;
private final ExpiringMap<String, String> verificationCodes;
private final Set<String> verifiedPlayers;
private boolean canSendMail;
@Inject
VerificationCodeManager(Settings settings, DataSource dataSource, EmailService emailService,
PermissionsManager permissionsManager) {
this.emailService = emailService;
this.dataSource = dataSource;
this.permissionsManager = permissionsManager;
verifiedPlayers = new HashSet<>();
long countTimeout = settings.getProperty(SecuritySettings.VERIFICATION_CODE_EXPIRATION_MINUTES);
verificationCodes = new ExpiringMap<>(countTimeout, TimeUnit.MINUTES);
reload(settings);
}
/**
* Returns if it is possible to send emails
*
* @return true if the service is enabled, false otherwise
*/
public boolean canSendMail() {
return canSendMail;
}
/**
* Returns whether the given player is able to verify his identity
*
* @param player the player to verify
* @return true if the player has not been verified yet, false otherwise
*/
public boolean isVerificationRequired(Player player) {
final String name = player.getName();
return canSendMail
&& !isPlayerVerified(name)
&& permissionsManager.hasPermission(player, PlayerPermission.VERIFICATION_CODE)
&& hasEmail(name);
}
/**
* Returns whether the given player is required to verify his identity through a command
*
* @param name the name of the player to verify
* @return true if the player has an existing code and has not been verified yet, false otherwise
*/
public boolean isCodeRequired(String name) {
return canSendMail && hasCode(name) && !isPlayerVerified(name);
}
/**
* Returns whether the given player has been verified or not
*
* @param name the name of the player to verify
* @return true if the player has been verified, false otherwise
*/
private boolean isPlayerVerified(String name) {
return verifiedPlayers.contains(name.toLowerCase());
}
/**
* Returns if a code exists for the player
*
* @param name the name of the player to verify
* @return true if the code exists, false otherwise
*/
public boolean hasCode(String name) {
return (verificationCodes.get(name.toLowerCase()) != null);
}
/**
* Returns whether the given player is able to receive emails
*
* @param name the name of the player to verify
* @return true if the player is able to receive emails, false otherwise
*/
public boolean hasEmail(String name) {
boolean result = false;
DataSourceResult<String> emailResult = dataSource.getEmail(name);
if (emailResult.playerExists()) {
final String email = emailResult.getValue();
if (!Utils.isEmailEmpty(email)) {
result = true;
}
}
return result;
}
/**
* Check if a code exists for the player or generates and saves a new one.
*
* @param name the player's name
*/
public void codeExistOrGenerateNew(String name) {
if (!hasCode(name)) {
generateCode(name);
}
}
/**
* Generates a code for the player and returns it.
*
* @param name the name of the player to generate a code for
*/
private void generateCode(String name) {
DataSourceResult<String> emailResult = dataSource.getEmail(name);
if (emailResult.playerExists()) {
final String email = emailResult.getValue();
if (!Utils.isEmailEmpty(email)) {
String code = RandomStringUtils.generateNum(6); // 6 digits code
verificationCodes.put(name.toLowerCase(), code);
emailService.sendVerificationMail(name, email, code);
}
}
}
/**
* Checks the given code against the existing one.
*
* @param name the name of the player to check
* @param code the supplied code
* @return true if the code matches, false otherwise
*/
public boolean checkCode(String name, String code) {
boolean correct = false;
if (code.equals(verificationCodes.get(name.toLowerCase()))) {
verify(name);
correct = true;
}
return correct;
}
/**
* Add the user to the set of verified users
*
* @param name the name of the player to generate a code for
*/
public void verify(String name){
verifiedPlayers.add(name.toLowerCase());
}
/**
* Remove the user from the set of verified users
*
* @param name the name of the player to generate a code for
*/
public void unverify(String name){
verifiedPlayers.remove(name.toLowerCase());
}
@Override
public void reload(Settings settings) {
canSendMail = emailService.hasAllInformation();
long countTimeout = settings.getProperty(SecuritySettings.VERIFICATION_CODE_EXPIRATION_MINUTES);
verificationCodes.setExpiration(countTimeout, TimeUnit.MINUTES);
}
@Override
public void performCleanup() {
verificationCodes.removeExpiredEntries();
}
}
@@ -67,7 +67,7 @@ public class EmailService {
File file = null;
if (settings.getProperty(EmailSettings.PASSWORD_AS_IMAGE)) {
try {
file = generateImage(name, newPass);
file = generatePasswordImage(name, newPass);
mailText = embedImageIntoEmailContent(file, email, mailText);
} catch (IOException | EmailException e) {
ConsoleLogger.logException(
@@ -80,6 +80,33 @@ public class EmailService {
return couldSendEmail;
}
/**
* Sends an email to the user with the temporary verification code.
*
* @param name the name of the player
* @param mailAddress the player's email
* @param code the verification code
* @return true if email could be sent, false otherwise
*/
public boolean sendVerificationMail(String name, String mailAddress, String code) {
if (!hasAllInformation()) {
ConsoleLogger.warning("Cannot send verification email: not all email settings are complete");
return false;
}
HtmlEmail email;
try {
email = sendMailSsl.initializeMail(mailAddress);
} catch (EmailException e) {
ConsoleLogger.logException("Failed to create verification email with the given settings:", e);
return false;
}
String mailText = replaceTagsForVerificationEmail(settings.getVerificationEmailMessage(), name, code,
settings.getProperty(SecuritySettings.VERIFICATION_CODE_EXPIRATION_MINUTES));
return sendMailSsl.sendEmail(mailText, email);
}
/**
* Sends an email to the user with a recovery code for the password recovery process.
*
@@ -102,7 +129,7 @@ public class EmailService {
return sendMailSsl.sendEmail(message, htmlEmail);
}
private File generateImage(String name, String newPass) throws IOException {
private File generatePasswordImage(String name, String newPass) throws IOException {
ImageGenerator gen = new ImageGenerator(newPass);
File file = new File(dataFolder, name + "_new_pass.jpg");
ImageIO.write(gen.generateImage(), "jpg", file);
@@ -123,6 +150,14 @@ public class EmailService {
.replace("<generatedpass />", newPass);
}
private String replaceTagsForVerificationEmail(String mailText, String name, String code, int minutesValid) {
return mailText
.replace("<playername />", name)
.replace("<servername />", serverName)
.replace("<generatedcode />", code)
.replace("<minutesvalid />", String.valueOf(minutesValid));
}
private String replaceTagsForRecoveryCodeMail(String mailText, String name, String code, int hoursValid) {
return mailText
.replace("<playername />", name)
@@ -239,6 +239,36 @@ public enum MessageKey {
/** An email was already sent recently. You must wait %time before you can send a new one. */
EMAIL_COOLDOWN_ERROR("email_cooldown_error", "%time"),
/**
* The command you are trying to execute is sensitive and requires a verification!
* A verification code has been sent to your email,
* run the command "/verification [code]" to verify your identity.
*/
VERIFICATION_CODE_REQUIRED("verification_code_required"),
/** Usage: /verification &lt;code&gt; */
USAGE_VERIFICATION_CODE("usage_verification_code"),
/** Incorrect code, please type "/verification &lt;code&gt;" into the chat! */
INCORRECT_VERIFICATION_CODE("incorrect_verification_code"),
/**
* Your identity has been verified!
* You can now execute every sensitive command within the current session!
*/
VERIFICATION_CODE_VERIFIED("verification_code_verified"),
/**
* You can already execute every sensitive command within the current session!
*/
VERIFICATION_CODE_ALREADY_VERIFIED("verification_code_already_verified"),
/** Your code has expired! Execute another sensitive command to get a new code! */
VERIFICATION_CODE_EXPIRED("verification_code_expired"),
/** To verify your identity you need to link an email address with your account! */
VERIFICATION_CODE_EMAIL_NEEDED("verification_code_email_needed"),
/** second */
SECOND("second"),
@@ -63,7 +63,12 @@ public enum PlayerPermission implements PermissionNode {
/**
* Permission to use to see own other accounts.
*/
SEE_OWN_ACCOUNTS("authme.player.seeownaccounts");
SEE_OWN_ACCOUNTS("authme.player.seeownaccounts"),
/**
* Permission to use the email verification codes feature.
*/
VERIFICATION_CODE("authme.player.security.verificationcode");
/**
* The permission node.
@@ -1,5 +1,6 @@
package fr.xephi.authme.process.logout;
import fr.xephi.authme.data.VerificationCodeManager;
import fr.xephi.authme.data.auth.PlayerAuth;
import fr.xephi.authme.data.auth.PlayerCache;
import fr.xephi.authme.datasource.DataSource;
@@ -26,6 +27,9 @@ public class AsynchronousLogout implements AsynchronousProcess {
@Inject
private PlayerCache playerCache;
@Inject
private VerificationCodeManager codeManager;
@Inject
private SyncProcessManager syncProcessManager;
@@ -52,6 +56,7 @@ public class AsynchronousLogout implements AsynchronousProcess {
}
playerCache.removePlayer(name);
codeManager.unverify(name);
database.setUnlogged(name);
database.revokeSession(name);
syncProcessManager.processSyncPlayerLogout(player);
@@ -1,6 +1,7 @@
package fr.xephi.authme.process.quit;
import fr.xephi.authme.AuthMe;
import fr.xephi.authme.data.VerificationCodeManager;
import fr.xephi.authme.data.auth.PlayerAuth;
import fr.xephi.authme.data.auth.PlayerCache;
import fr.xephi.authme.datasource.CacheDataSource;
@@ -44,6 +45,9 @@ public class AsynchronousQuit implements AsynchronousProcess {
@Inject
private ValidationService validationService;
@Inject
private VerificationCodeManager codeManager;
AsynchronousQuit() {
}
@@ -80,6 +84,7 @@ public class AsynchronousQuit implements AsynchronousProcess {
//always unauthenticate the player - use session only for auto logins on the same ip
playerCache.removePlayer(name);
codeManager.unverify(name);
//always update the database when the player quit the game (if sessions are disabled)
if (wasLoggedIn) {
@@ -20,6 +20,7 @@ public class Settings extends SettingsManager {
private final File pluginFolder;
private String passwordEmailMessage;
private String verificationEmailMessage;
private String recoveryCodeEmailMessage;
/**
@@ -46,6 +47,15 @@ public class Settings extends SettingsManager {
return passwordEmailMessage;
}
/**
* Return the text for verification emails (before sensitive commands can be used).
*
* @return The email message
*/
public String getVerificationEmailMessage() {
return verificationEmailMessage;
}
/**
* Return the text to use when someone requests to receive a recovery code.
*
@@ -57,6 +67,7 @@ public class Settings extends SettingsManager {
private void loadSettingsFromFiles() {
passwordEmailMessage = readFile("email.html");
verificationEmailMessage = readFile("verification_code_email.html");
recoveryCodeEmailMessage = readFile("recovery_code_email.html");
}
@@ -147,6 +147,10 @@ public final class SecuritySettings implements SettingsHolder {
public static final Property<Boolean> USE_EMAIL_MASKING =
newProperty("Security.privacy.enableEmailMasking", false);
@Comment("Minutes after which a verification code will expire")
public static final Property<Integer> VERIFICATION_CODE_EXPIRATION_MINUTES =
newProperty("Security.privacy.verificationCodeExpiration", 10);
private SecuritySettings() {
}
@@ -10,8 +10,9 @@ public final class RandomStringUtils {
private static final char[] CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
private static final Random RANDOM = new SecureRandom();
private static final int HEX_MAX_INDEX = 16;
private static final int NUM_INDEX = 10;
private static final int LOWER_ALPHANUMERIC_INDEX = 36;
private static final int HEX_MAX_INDEX = 16;
// Utility class
private RandomStringUtils() {
@@ -38,6 +39,17 @@ public final class RandomStringUtils {
return generateString(length, HEX_MAX_INDEX);
}
/**
* Generate a random numbers string of the given length. In other words, the generated string
* contains characters only within the range [0-9].
*
* @param length The length of the random string to generate
* @return The random numbers string
*/
public static String generateNum(int length) {
return generateString(length, NUM_INDEX);
}
/**
* Generate a random string with digits and lowercase and uppercase letters. The result of this
* method matches the pattern [0-9a-zA-Z].