From 0aac8928afe6efcffd7251518b9fcd2a54a63bc2 Mon Sep 17 00:00:00 2001 From: ljacqu Date: Sat, 10 Sep 2016 09:13:17 +0200 Subject: [PATCH] #472 Create recovery code/expiration columns and methods in data source --- .../authme/datasource/CacheDataSource.java | 18 ++++++ .../fr/xephi/authme/datasource/Columns.java | 4 ++ .../xephi/authme/datasource/DataSource.java | 24 ++++++++ .../fr/xephi/authme/datasource/FlatFile.java | 15 +++++ .../fr/xephi/authme/datasource/MySQL.java | 56 +++++++++++++++++++ .../fr/xephi/authme/datasource/SQLite.java | 56 +++++++++++++++++++ .../settings/properties/DatabaseSettings.java | 8 +++ src/main/resources/config.yml | 4 ++ src/test/java/fr/xephi/authme/TestHelper.java | 11 ++++ .../AbstractDataSourceIntegrationTest.java | 43 ++++++++++++++ .../datasource/MySqlIntegrationTest.java | 2 +- .../datasource/SQLiteIntegrationTest.java | 2 +- .../authme/datasource/sql-initialize.sql | 2 + 13 files changed, 243 insertions(+), 2 deletions(-) diff --git a/src/main/java/fr/xephi/authme/datasource/CacheDataSource.java b/src/main/java/fr/xephi/authme/datasource/CacheDataSource.java index a79cb5805..36372430c 100644 --- a/src/main/java/fr/xephi/authme/datasource/CacheDataSource.java +++ b/src/main/java/fr/xephi/authme/datasource/CacheDataSource.java @@ -235,6 +235,24 @@ public class CacheDataSource implements DataSource { return source.getAllAuths(); } + @Override + public void setRecoveryCode(String name, String code, long expiration) { + source.setRecoveryCode(name, code, expiration); + cachedAuths.refresh(name); + } + + @Override + public String getRecoveryCode(String name) { + // TODO #472: can probably get it from the cached Auth? + return source.getRecoveryCode(name); + } + + @Override + public void removeRecoveryCode(String name) { + source.removeRecoveryCode(name); + cachedAuths.refresh(name); + } + @Override public List getLoggedPlayers() { return new ArrayList<>(PlayerCache.getInstance().getCache().values()); diff --git a/src/main/java/fr/xephi/authme/datasource/Columns.java b/src/main/java/fr/xephi/authme/datasource/Columns.java index b6d732cdb..d56aae726 100644 --- a/src/main/java/fr/xephi/authme/datasource/Columns.java +++ b/src/main/java/fr/xephi/authme/datasource/Columns.java @@ -22,6 +22,8 @@ public final class Columns { public final String EMAIL; public final String ID; public final String IS_LOGGED; + public final String RECOVERY_CODE; + public final String RECOVERY_EXPIRATION; public Columns(Settings settings) { NAME = settings.getProperty(DatabaseSettings.MYSQL_COL_NAME); @@ -38,6 +40,8 @@ public final class Columns { EMAIL = settings.getProperty(DatabaseSettings.MYSQL_COL_EMAIL); ID = settings.getProperty(DatabaseSettings.MYSQL_COL_ID); IS_LOGGED = settings.getProperty(DatabaseSettings.MYSQL_COL_ISLOGGED); + RECOVERY_CODE = settings.getProperty(DatabaseSettings.MYSQL_COL_RECOVERY_CODE); + RECOVERY_EXPIRATION = settings.getProperty(DatabaseSettings.MYSQL_COL_RECOVERY_EXPIRATION); } } diff --git a/src/main/java/fr/xephi/authme/datasource/DataSource.java b/src/main/java/fr/xephi/authme/datasource/DataSource.java index 5b25fde44..96788d839 100644 --- a/src/main/java/fr/xephi/authme/datasource/DataSource.java +++ b/src/main/java/fr/xephi/authme/datasource/DataSource.java @@ -194,6 +194,30 @@ public interface DataSource extends Reloadable { */ List getAllAuths(); + /** + * Set the password recovery code for a user. + * + * @param name The name of the user + * @param code The recovery code + * @param expiration Recovery code expiration (milliseconds timestamp) + */ + void setRecoveryCode(String name, String code, long expiration); + + /** + * Get the recovery code of a user if available and not yet expired. + * + * @param name The name of the user + * @return The recovery code, or null if no current code available + */ + String getRecoveryCode(String name); + + /** + * Remove the recovery code of a given user. + * + * @param name The name of the user + */ + void removeRecoveryCode(String name); + /** * Reload the data source. */ diff --git a/src/main/java/fr/xephi/authme/datasource/FlatFile.java b/src/main/java/fr/xephi/authme/datasource/FlatFile.java index f3f180146..d600a3e67 100644 --- a/src/main/java/fr/xephi/authme/datasource/FlatFile.java +++ b/src/main/java/fr/xephi/authme/datasource/FlatFile.java @@ -468,6 +468,21 @@ public class FlatFile implements DataSource { throw new UnsupportedOperationException("Flat file no longer supported"); } + @Override + public void setRecoveryCode(String name, String code, long expiration) { + throw new UnsupportedOperationException("Flat file no longer supported"); + } + + @Override + public String getRecoveryCode(String name) { + throw new UnsupportedOperationException("Flat file no longer supported"); + } + + @Override + public void removeRecoveryCode(String name) { + throw new UnsupportedOperationException("Flat file no longer supported"); + } + private static PlayerAuth buildAuthFromArray(String[] args) { // Format allows 2, 3, 4, 7, 8, 9 fields. Anything else is unknown if (args.length >= 2 && args.length <= 9 && args.length != 5 && args.length != 6) { diff --git a/src/main/java/fr/xephi/authme/datasource/MySQL.java b/src/main/java/fr/xephi/authme/datasource/MySQL.java index dc08d7b46..5b9440448 100644 --- a/src/main/java/fr/xephi/authme/datasource/MySQL.java +++ b/src/main/java/fr/xephi/authme/datasource/MySQL.java @@ -208,6 +208,14 @@ public class MySQL implements DataSource { st.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + col.IS_LOGGED + " SMALLINT NOT NULL DEFAULT '0' AFTER " + col.EMAIL); } + + if (isColumnMissing(md, col.RECOVERY_CODE)) { + st.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + col.RECOVERY_CODE + " VARCHAR(20);"); + } + + if (isColumnMissing(md, col.RECOVERY_EXPIRATION)) { + st.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + col.RECOVERY_EXPIRATION + " BIGINT;"); + } } ConsoleLogger.info("MySQL setup finished"); } @@ -856,6 +864,54 @@ public class MySQL implements DataSource { return auths; } + @Override + public void setRecoveryCode(String name, String code, long expiration) { + String sql = "UPDATE " + tableName + + " SET " + col.RECOVERY_CODE + " = ?, " + + col.RECOVERY_EXPIRATION + " = ?" + + " WHERE " + col.NAME + " = ?;"; + try (Connection con = getConnection(); PreparedStatement pst = con.prepareStatement(sql)) { + pst.setString(1, code); + pst.setLong(2, expiration); + pst.setString(3, name.toLowerCase()); + pst.executeUpdate(); + } catch (SQLException e) { + logSqlException(e); + } + } + + @Override + public String getRecoveryCode(String name) { + String sql = "SELECT " + col.RECOVERY_CODE + " FROM " + tableName + + " WHERE " + col.NAME + " = ? AND " + col.RECOVERY_EXPIRATION + " > ?;"; + try (Connection con = getConnection(); PreparedStatement pst = con.prepareStatement(sql)) { + pst.setString(1, name.toLowerCase()); + pst.setLong(2, System.currentTimeMillis()); + try (ResultSet rs = pst.executeQuery()) { + if (rs.next()) { + return rs.getString(1); + } + } + } catch (SQLException e) { + logSqlException(e); + } + return null; + } + + @Override + public void removeRecoveryCode(String name) { + String sql = "UPDATE " + tableName + + " SET " + col.RECOVERY_CODE + " = NULL" + + " AND " + col.RECOVERY_EXPIRATION + " = NULL" + + " WHERE " + col.NAME + " = ?;"; + try (Connection con = getConnection(); PreparedStatement pst = con.prepareStatement(sql)) { + pst.setString(1, name.toLowerCase()); + pst.executeUpdate(); + } catch (SQLException e) { + logSqlException(e); + } + } + private PlayerAuth buildAuthFromResultSet(ResultSet row) throws SQLException { String salt = col.SALT.isEmpty() ? null : row.getString(col.SALT); int group = col.GROUP.isEmpty() ? -1 : row.getInt(col.GROUP); diff --git a/src/main/java/fr/xephi/authme/datasource/SQLite.java b/src/main/java/fr/xephi/authme/datasource/SQLite.java index 8aae94c58..df90d2eb2 100644 --- a/src/main/java/fr/xephi/authme/datasource/SQLite.java +++ b/src/main/java/fr/xephi/authme/datasource/SQLite.java @@ -127,6 +127,14 @@ public class SQLite implements DataSource { if (isColumnMissing(md, col.IS_LOGGED)) { st.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + col.IS_LOGGED + " INT DEFAULT '0';"); } + + if (isColumnMissing(md, col.RECOVERY_CODE)) { + st.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + col.RECOVERY_CODE + " VARCHAR(20);"); + } + + if (isColumnMissing(md, col.RECOVERY_EXPIRATION)) { + st.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + col.RECOVERY_EXPIRATION + " BIGINT;"); + } } ConsoleLogger.info("SQLite Setup finished"); } @@ -586,6 +594,54 @@ public class SQLite implements DataSource { return auths; } + @Override + public void setRecoveryCode(String name, String code, long expiration) { + String sql = "UPDATE " + tableName + + " SET " + col.RECOVERY_CODE + " = ?, " + + col.RECOVERY_EXPIRATION + " = ?" + + " WHERE " + col.NAME + " = ?;"; + try (PreparedStatement pst = con.prepareStatement(sql)) { + pst.setString(1, code); + pst.setLong(2, expiration); + pst.setString(3, name.toLowerCase()); + pst.executeUpdate(); + } catch (SQLException e) { + logSqlException(e); + } + } + + @Override + public String getRecoveryCode(String name) { + String sql = "SELECT " + col.RECOVERY_CODE + " FROM " + tableName + + " WHERE " + col.NAME + " = ? AND " + col.RECOVERY_EXPIRATION + " > ?;"; + try (PreparedStatement pst = con.prepareStatement(sql)) { + pst.setString(1, name.toLowerCase()); + pst.setLong(2, System.currentTimeMillis()); + try (ResultSet rs = pst.executeQuery()) { + if (rs.next()) { + return rs.getString(1); + } + } + } catch (SQLException e) { + logSqlException(e); + } + return null; + } + + @Override + public void removeRecoveryCode(String name) { + String sql = "UPDATE " + tableName + + " SET " + col.RECOVERY_CODE + " = NULL" + + " AND " + col.RECOVERY_EXPIRATION + " = NULL" + + " WHERE " + col.NAME + " = ?;"; + try (PreparedStatement pst = con.prepareStatement(sql)) { + pst.setString(1, name.toLowerCase()); + pst.executeUpdate(); + } catch (SQLException e) { + logSqlException(e); + } + } + private PlayerAuth buildAuthFromResultSet(ResultSet row) throws SQLException { String salt = !col.SALT.isEmpty() ? row.getString(col.SALT) : null; 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 2dc769ec6..9b76c09c8 100644 --- a/src/main/java/fr/xephi/authme/settings/properties/DatabaseSettings.java +++ b/src/main/java/fr/xephi/authme/settings/properties/DatabaseSettings.java @@ -98,6 +98,14 @@ public class DatabaseSettings implements SettingsHolder { public static final Property MYSQL_COL_GROUP = newProperty("ExternalBoardOptions.mySQLColumnGroup", ""); + @Comment("Column for storing recovery code (when password lost)") + public static final Property MYSQL_COL_RECOVERY_CODE = + newProperty("DataSource.mySQLrecoveryCode", "recoverycode"); + + @Comment("Column for storing recovery code expiration") + public static final Property MYSQL_COL_RECOVERY_EXPIRATION = + newProperty("DataSource.mySQLrecoveryExpiration", "recoveryexpiration"); + private DatabaseSettings() { } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 07f3c3c7e..a744f43cd 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -40,6 +40,10 @@ DataSource: mySQLlastlocWorld: world # Column for RealName mySQLRealName: realname + # Column for storing recovery code (when password lost) + mySQLrecoveryCode: recoverycode + # Column for storing recovery code expiration + mySQLrecoveryExpiration: recoveryexpiration settings: # The name shown in the help messages. helpHeader: AuthMeReloaded diff --git a/src/test/java/fr/xephi/authme/TestHelper.java b/src/test/java/fr/xephi/authme/TestHelper.java index e6435b5a9..f0f9da8d1 100644 --- a/src/test/java/fr/xephi/authme/TestHelper.java +++ b/src/test/java/fr/xephi/authme/TestHelper.java @@ -126,6 +126,17 @@ public final class TestHelper { return logger; } + /** + * Set ConsoleLogger to use a new real logger. + * + * @return The real logger used by ConsoleLogger + */ + public static Logger setRealLogger() { + Logger logger = Logger.getAnonymousLogger(); + ConsoleLogger.setLogger(logger); + return logger; + } + /** * Check that a class only has a hidden, zero-argument constructor, preventing the * instantiation of such classes (utility classes). Invokes the hidden constructor diff --git a/src/test/java/fr/xephi/authme/datasource/AbstractDataSourceIntegrationTest.java b/src/test/java/fr/xephi/authme/datasource/AbstractDataSourceIntegrationTest.java index 7fbccdd4a..9c417f622 100644 --- a/src/test/java/fr/xephi/authme/datasource/AbstractDataSourceIntegrationTest.java +++ b/src/test/java/fr/xephi/authme/datasource/AbstractDataSourceIntegrationTest.java @@ -382,4 +382,47 @@ public abstract class AbstractDataSourceIntegrationTest { assertThat(dataSource.getAllAuths(), empty()); } + @Test + public void shouldSetRecoveryCode() { + // given + DataSource dataSource = getDataSource(); + String name = "Bobby"; + String code = "A123BC"; + + // when + dataSource.setRecoveryCode(name, code, System.currentTimeMillis() + 100_000L); + + // then + assertThat(dataSource.getRecoveryCode(name), equalTo(code)); + } + + @Test + public void shouldRemoveRecoveryCode() { + // given + String name = "User"; + DataSource dataSource = getDataSource(); + dataSource.setRecoveryCode(name, "code", System.currentTimeMillis() + 20_000L); + + // when + dataSource.removeRecoveryCode(name); + + // then + assertThat(dataSource.getRecoveryCode(name), nullValue()); + assertThat(dataSource.getRecoveryCode("bobby"), nullValue()); + } + + @Test + public void shouldNotReturnRecoveryCodeIfExpired() { + // given + String name = "user"; + DataSource dataSource = getDataSource(); + dataSource.setRecoveryCode(name, "123456", System.currentTimeMillis() - 2_000L); + + // when + String code = dataSource.getRecoveryCode(name); + + // then + assertThat(code, nullValue()); + } + } diff --git a/src/test/java/fr/xephi/authme/datasource/MySqlIntegrationTest.java b/src/test/java/fr/xephi/authme/datasource/MySqlIntegrationTest.java index ca8089216..93d8a3998 100644 --- a/src/test/java/fr/xephi/authme/datasource/MySqlIntegrationTest.java +++ b/src/test/java/fr/xephi/authme/datasource/MySqlIntegrationTest.java @@ -53,7 +53,7 @@ public class MySqlIntegrationTest extends AbstractDataSourceIntegrationTest { }); set(DatabaseSettings.MYSQL_DATABASE, "h2_test"); set(DatabaseSettings.MYSQL_TABLE, "authme"); - TestHelper.setupLogger(); + TestHelper.setRealLogger(); Path sqlInitFile = TestHelper.getJarPath(TestHelper.PROJECT_ROOT + "datasource/sql-initialize.sql"); sqlInitialize = new String(Files.readAllBytes(sqlInitFile)); diff --git a/src/test/java/fr/xephi/authme/datasource/SQLiteIntegrationTest.java b/src/test/java/fr/xephi/authme/datasource/SQLiteIntegrationTest.java index e0eb3eca8..4836b198d 100644 --- a/src/test/java/fr/xephi/authme/datasource/SQLiteIntegrationTest.java +++ b/src/test/java/fr/xephi/authme/datasource/SQLiteIntegrationTest.java @@ -56,7 +56,7 @@ public class SQLiteIntegrationTest extends AbstractDataSourceIntegrationTest { }); set(DatabaseSettings.MYSQL_DATABASE, "sqlite-test"); set(DatabaseSettings.MYSQL_TABLE, "authme"); - TestHelper.setupLogger(); + TestHelper.setRealLogger(); Path sqlInitFile = TestHelper.getJarPath(TestHelper.PROJECT_ROOT + "datasource/sql-initialize.sql"); // Note ljacqu 20160221: It appears that we can only run one statement per Statement.execute() so we split diff --git a/src/test/resources/fr/xephi/authme/datasource/sql-initialize.sql b/src/test/resources/fr/xephi/authme/datasource/sql-initialize.sql index 5dea47d51..48891c747 100644 --- a/src/test/resources/fr/xephi/authme/datasource/sql-initialize.sql +++ b/src/test/resources/fr/xephi/authme/datasource/sql-initialize.sql @@ -13,6 +13,8 @@ CREATE TABLE authme ( email VARCHAR(255) DEFAULT 'your@email.com', isLogged INT DEFAULT '0', realname VARCHAR(255) NOT NULL DEFAULT 'Player', salt varchar(255), + recoverycode VARCHAR(20), + recoveryexpiration BIGINT, CONSTRAINT table_const_prim PRIMARY KEY (id) );