Skip to content

Commit 4d52043

Browse files
committed
feat: Add LibreLogin, LimboAuth and nLogin database converters
1 parent 9a79677 commit 4d52043

10 files changed

Lines changed: 567 additions & 3 deletions

File tree

authme-core/src/main/java/fr/xephi/authme/command/executable/authme/ConverterCommand.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
import fr.xephi.authme.command.ExecutableCommand;
88
import fr.xephi.authme.datasource.converter.AuthPlusConverter;
99
import fr.xephi.authme.datasource.converter.Converter;
10+
import fr.xephi.authme.datasource.converter.LibreLoginConverter;
11+
import fr.xephi.authme.datasource.converter.LimboAuthConverter;
1012
import fr.xephi.authme.datasource.converter.MySqlToSqlite;
13+
import fr.xephi.authme.datasource.converter.NLoginConverter;
1114
import fr.xephi.authme.datasource.converter.SqliteToSql;
1215
import fr.xephi.authme.message.MessageKey;
1316
import fr.xephi.authme.output.ConsoleLoggerFactory;
@@ -78,6 +81,9 @@ private static Class<? extends Converter> getConverterClassFromArgs(List<String>
7881
private static Map<String, Class<? extends Converter>> getConverters() {
7982
return ImmutableSortedMap.<String, Class<? extends Converter>>naturalOrder()
8083
.put("authplus", AuthPlusConverter.class)
84+
.put("librelogin", LibreLoginConverter.class)
85+
.put("limboauth", LimboAuthConverter.class)
86+
.put("nlogin", NLoginConverter.class)
8187
.put("sqlitetosql", SqliteToSql.class)
8288
.put("mysqltosqlite", MySqlToSqlite.class)
8389
.build();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package fr.xephi.authme.datasource.converter;
2+
3+
import fr.xephi.authme.datasource.DataSource;
4+
import fr.xephi.authme.settings.Settings;
5+
import fr.xephi.authme.settings.properties.DatabaseSettings;
6+
7+
import java.sql.Connection;
8+
import java.sql.DriverManager;
9+
import java.sql.SQLException;
10+
11+
/**
12+
* Base class for converters that read from an external plugin's MySQL/MariaDB table.
13+
* <p>
14+
* The source database is assumed to share the same host, port, database name, and credentials
15+
* as configured in AuthMe's {@code config.yml}. SQLite source databases are not supported.
16+
*/
17+
abstract class AbstractSqlPluginConverter implements Converter {
18+
19+
private final DataSource dataSource;
20+
private final Settings settings;
21+
22+
AbstractSqlPluginConverter(Settings settings, DataSource dataSource) {
23+
this.settings = settings;
24+
this.dataSource = dataSource;
25+
}
26+
27+
protected DataSource getDataSource() {
28+
return dataSource;
29+
}
30+
31+
/**
32+
* Opens a JDBC connection to the database configured in AuthMe's settings.
33+
*
34+
* @return an open connection
35+
* @throws SQLException if the connection cannot be established
36+
*/
37+
protected Connection openConnection() throws SQLException {
38+
String host = settings.getProperty(DatabaseSettings.MYSQL_HOST);
39+
String port = settings.getProperty(DatabaseSettings.MYSQL_PORT);
40+
String database = settings.getProperty(DatabaseSettings.MYSQL_DATABASE);
41+
String user = settings.getProperty(DatabaseSettings.MYSQL_USERNAME);
42+
String pass = settings.getProperty(DatabaseSettings.MYSQL_PASSWORD);
43+
String url = "jdbc:mysql://" + host + ":" + port + "/" + database
44+
+ "?useUnicode=true&characterEncoding=utf-8";
45+
return DriverManager.getConnection(url, user, pass);
46+
}
47+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package fr.xephi.authme.datasource.converter;
2+
3+
import fr.xephi.authme.ConsoleLogger;
4+
import fr.xephi.authme.data.auth.PlayerAuth;
5+
import fr.xephi.authme.datasource.DataSource;
6+
import fr.xephi.authme.output.ConsoleLoggerFactory;
7+
import fr.xephi.authme.security.crypts.HashedPassword;
8+
import fr.xephi.authme.settings.Settings;
9+
import fr.xephi.authme.util.UuidUtils;
10+
import org.bukkit.command.CommandSender;
11+
12+
import javax.inject.Inject;
13+
import java.sql.Connection;
14+
import java.sql.PreparedStatement;
15+
import java.sql.ResultSet;
16+
import java.sql.SQLException;
17+
import java.sql.Timestamp;
18+
import java.util.Base64;
19+
import java.util.Locale;
20+
import java.nio.charset.StandardCharsets;
21+
22+
import static fr.xephi.authme.util.Utils.logAndSendMessage;
23+
24+
/**
25+
* Converts data from LibreLogin to AuthMe.
26+
* <p>
27+
* LibreLogin stores accounts in the {@code librepremium_data} table of the configured database.
28+
* This converter expects that LibreLogin and AuthMe share the same MySQL/MariaDB database.
29+
* <p>
30+
* <b>Algorithm mapping:</b>
31+
* <ul>
32+
* <li>{@code BCrypt-2A} → configure AuthMe with {@code passwordHash: BCRYPT}</li>
33+
* <li>{@code Argon2-ID} → configure AuthMe with {@code passwordHash: ARGON2}</li>
34+
* <li>{@code SHA-256} → configure AuthMe with {@code passwordHash: SHA256} (same computation)</li>
35+
* <li>{@code SHA-512} → configure AuthMe with {@code passwordHash: DOUBLE_SHA512}</li>
36+
* <li>{@code LOGIT-SHA-256} → configure AuthMe with {@code passwordHash: SALTEDSHA256}</li>
37+
* </ul>
38+
* If accounts use mixed algorithms, set the most common one in AuthMe's config and have remaining
39+
* players reset their password.
40+
*/
41+
public class LibreLoginConverter extends AbstractSqlPluginConverter {
42+
43+
private static final String TABLE = "librepremium_data";
44+
private static final String QUERY = "SELECT last_nickname, hashed_password, salt, algo, "
45+
+ "ip, email, joined, last_seen, uuid, premium_uuid, secret FROM " + TABLE;
46+
47+
private final ConsoleLogger logger = ConsoleLoggerFactory.get(LibreLoginConverter.class);
48+
49+
@Inject
50+
LibreLoginConverter(Settings settings, DataSource dataSource) {
51+
super(settings, dataSource);
52+
}
53+
54+
@Override
55+
public void execute(CommandSender sender) {
56+
try (Connection conn = openConnection();
57+
PreparedStatement ps = conn.prepareStatement(QUERY);
58+
ResultSet rs = ps.executeQuery()) {
59+
60+
long imported = 0;
61+
long skipped = 0;
62+
while (rs.next()) {
63+
String realName = rs.getString("last_nickname");
64+
if (realName == null || realName.isEmpty()) {
65+
continue;
66+
}
67+
String name = realName.toLowerCase(Locale.ROOT);
68+
69+
if (getDataSource().isAuthAvailable(name)) {
70+
++skipped;
71+
continue;
72+
}
73+
74+
HashedPassword password = buildHashedPassword(
75+
rs.getString("hashed_password"),
76+
rs.getString("salt"),
77+
rs.getString("algo"),
78+
name);
79+
if (password == null) {
80+
continue;
81+
}
82+
83+
Timestamp joined = rs.getTimestamp("joined");
84+
Timestamp lastSeen = rs.getTimestamp("last_seen");
85+
86+
PlayerAuth.Builder builder = PlayerAuth.builder()
87+
.name(name)
88+
.realName(realName)
89+
.password(password)
90+
.lastIp(rs.getString("ip"))
91+
.email(rs.getString("email"))
92+
.registrationDate(joined != null ? joined.getTime() : 0L)
93+
.lastLogin(lastSeen != null ? lastSeen.getTime() : null)
94+
.totpKey(rs.getString("secret"))
95+
.uuid(UuidUtils.parseUuidSafely(rs.getString("uuid")))
96+
.premiumUuid(UuidUtils.parseUuidSafely(rs.getString("premium_uuid")));
97+
98+
getDataSource().saveAuth(builder.build());
99+
++imported;
100+
}
101+
102+
logAndSendMessage(sender, "LibreLogin conversion: " + imported + " account(s) imported, "
103+
+ skipped + " skipped (already exist)");
104+
105+
} catch (SQLException e) {
106+
logAndSendMessage(sender, "LibreLogin conversion failed: " + e.getMessage());
107+
logger.logException("LibreLogin conversion error:", e);
108+
}
109+
}
110+
111+
private HashedPassword buildHashedPassword(String hash, String salt, String algo, String name) {
112+
if (hash == null || algo == null) {
113+
return null;
114+
}
115+
switch (algo) {
116+
case "BCrypt-2A":
117+
return new HashedPassword(hash);
118+
119+
case "Argon2-ID":
120+
// LibreLogin stores Argon2 as a Base64-encoded PHC string
121+
String phc = decodeArgon2(hash);
122+
if (phc == null) {
123+
logger.warning("Could not decode Argon2-ID hash for player '" + name + "', skipping");
124+
return null;
125+
}
126+
return new HashedPassword(phc);
127+
128+
case "SHA-256":
129+
// LibreLogin SHA-256: sha256(sha256(pw) + salt) — same computation as AuthMe SHA256.
130+
// Reconstruct the AuthMe format: $SHA$<salt>$<hash>
131+
if (salt == null) {
132+
logger.warning("Null salt for SHA-256 account '" + name + "', skipping");
133+
return null;
134+
}
135+
return new HashedPassword("$SHA$" + salt + "$" + hash);
136+
137+
case "SHA-512":
138+
// LibreLogin SHA-512: sha512(sha512(pw) + salt) — maps to AuthMe DOUBLE_SHA512 (separate salt).
139+
if (salt == null) {
140+
logger.warning("Null salt for SHA-512 account '" + name + "', skipping");
141+
return null;
142+
}
143+
return new HashedPassword(hash, salt);
144+
145+
case "LOGIT-SHA-256":
146+
// LibreLogin LOGIT-SHA-256: sha256(pw + salt) — maps to AuthMe SALTEDSHA256 (separate salt).
147+
if (salt == null) {
148+
logger.warning("Null salt for LOGIT-SHA-256 account '" + name + "', skipping");
149+
return null;
150+
}
151+
return new HashedPassword(hash, salt);
152+
153+
default:
154+
logger.warning("Unknown LibreLogin algorithm '" + algo + "' for player '" + name + "', skipping");
155+
return null;
156+
}
157+
}
158+
159+
private String decodeArgon2(String value) {
160+
if (value.startsWith("$argon2")) {
161+
return value;
162+
}
163+
try {
164+
String decoded = new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8);
165+
if (decoded.startsWith("$argon2")) {
166+
return decoded;
167+
}
168+
} catch (IllegalArgumentException ignored) {
169+
// fall through
170+
}
171+
return null;
172+
}
173+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package fr.xephi.authme.datasource.converter;
2+
3+
import fr.xephi.authme.ConsoleLogger;
4+
import fr.xephi.authme.data.auth.PlayerAuth;
5+
import fr.xephi.authme.datasource.DataSource;
6+
import fr.xephi.authme.output.ConsoleLoggerFactory;
7+
import fr.xephi.authme.security.crypts.HashedPassword;
8+
import fr.xephi.authme.settings.Settings;
9+
import fr.xephi.authme.util.UuidUtils;
10+
import org.bukkit.command.CommandSender;
11+
12+
import javax.inject.Inject;
13+
import java.sql.Connection;
14+
import java.sql.PreparedStatement;
15+
import java.sql.ResultSet;
16+
import java.sql.SQLException;
17+
import java.util.Locale;
18+
19+
import static fr.xephi.authme.util.Utils.logAndSendMessage;
20+
21+
/**
22+
* Converts data from LimboAuth to AuthMe.
23+
* <p>
24+
* LimboAuth stores accounts in the {@code AUTH} table of the configured database.
25+
* This converter expects that LimboAuth and AuthMe share the same MySQL/MariaDB database.
26+
* <p>
27+
* LimboAuth uses BCrypt exclusively for new registrations, so configure AuthMe with
28+
* {@code passwordHash: BCRYPT} before running this converter.
29+
*/
30+
public class LimboAuthConverter extends AbstractSqlPluginConverter {
31+
32+
private static final String TABLE = "AUTH";
33+
private static final String QUERY = "SELECT NICKNAME, LOWERCASENICKNAME, HASH, IP, "
34+
+ "REGDATE, LOGINDATE, UUID, PREMIUMUUID, TOTPTOKEN FROM " + TABLE;
35+
36+
private final ConsoleLogger logger = ConsoleLoggerFactory.get(LimboAuthConverter.class);
37+
38+
@Inject
39+
LimboAuthConverter(Settings settings, DataSource dataSource) {
40+
super(settings, dataSource);
41+
}
42+
43+
@Override
44+
public void execute(CommandSender sender) {
45+
try (Connection conn = openConnection();
46+
PreparedStatement ps = conn.prepareStatement(QUERY);
47+
ResultSet rs = ps.executeQuery()) {
48+
49+
long imported = 0;
50+
long skipped = 0;
51+
while (rs.next()) {
52+
String realName = rs.getString("NICKNAME");
53+
String name = rs.getString("LOWERCASENICKNAME");
54+
if (name == null || name.isEmpty()) {
55+
if (realName != null) {
56+
name = realName.toLowerCase(Locale.ROOT);
57+
} else {
58+
continue;
59+
}
60+
}
61+
if (realName == null) {
62+
realName = name;
63+
}
64+
65+
if (getDataSource().isAuthAvailable(name)) {
66+
++skipped;
67+
continue;
68+
}
69+
70+
String hash = rs.getString("HASH");
71+
if (hash == null || hash.isEmpty()) {
72+
logger.warning("No hash for player '" + name + "', skipping");
73+
continue;
74+
}
75+
76+
long regDate = rs.getLong("REGDATE");
77+
long loginDate = rs.getLong("LOGINDATE");
78+
79+
PlayerAuth.Builder builder = PlayerAuth.builder()
80+
.name(name)
81+
.realName(realName)
82+
.password(new HashedPassword(hash))
83+
.lastIp(rs.getString("IP"))
84+
.registrationDate(regDate)
85+
.lastLogin(loginDate > 0 ? loginDate : null)
86+
.totpKey(rs.getString("TOTPTOKEN"))
87+
.uuid(UuidUtils.parseUuidSafely(rs.getString("UUID")))
88+
.premiumUuid(UuidUtils.parseUuidSafely(rs.getString("PREMIUMUUID")));
89+
90+
getDataSource().saveAuth(builder.build());
91+
++imported;
92+
}
93+
94+
logAndSendMessage(sender, "LimboAuth conversion: " + imported + " account(s) imported, "
95+
+ skipped + " skipped (already exist)");
96+
97+
} catch (SQLException e) {
98+
logAndSendMessage(sender, "LimboAuth conversion failed: " + e.getMessage());
99+
logger.logException("LimboAuth conversion error:", e);
100+
}
101+
}
102+
}

0 commit comments

Comments
 (0)