You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
🆕 Added Reset password / Email and chenge username in user settings
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN `last_username_change` INT(11) NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO emulator_settings (`key`, `value`, `comment`)
|
||||||
|
VALUES ('rename.cooldown_days', '30', 'Days between username changes');
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.eu.habbo.habbohotel.commands;
|
|
||||||
|
|
||||||
import com.eu.habbo.Emulator;
|
|
||||||
import com.eu.habbo.habbohotel.gameclients.GameClient;
|
|
||||||
import com.eu.habbo.messages.outgoing.users.UserDataComposer;
|
|
||||||
|
|
||||||
public class ChangeNameCommand extends Command {
|
|
||||||
public ChangeNameCommand() {
|
|
||||||
super("cmd_changename", Emulator.getTexts().getValue("commands.keys.cmd_changename").split(";"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean handle(GameClient gameClient, String[] params) throws Exception {
|
|
||||||
gameClient.getHabbo().getHabboStats().allowNameChange = !gameClient.getHabbo().getHabboStats().allowNameChange;
|
|
||||||
gameClient.sendResponse(new UserDataComposer(gameClient.getHabbo()));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -184,7 +184,6 @@ public class CommandHandler {
|
|||||||
addCommand(new BlockAlertCommand());
|
addCommand(new BlockAlertCommand());
|
||||||
addCommand(new BotsCommand());
|
addCommand(new BotsCommand());
|
||||||
addCommand(new CalendarCommand());
|
addCommand(new CalendarCommand());
|
||||||
addCommand(new ChangeNameCommand());
|
|
||||||
addCommand(new ChatTypeCommand());
|
addCommand(new ChatTypeCommand());
|
||||||
addCommand(new CommandsCommand());
|
addCommand(new CommandsCommand());
|
||||||
addCommand(new ControlCommand());
|
addCommand(new ControlCommand());
|
||||||
|
|||||||
+540
-2
@@ -38,6 +38,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
private static final String REFRESH_PATH = "/api/auth/refresh";
|
private static final String REFRESH_PATH = "/api/auth/refresh";
|
||||||
private static final String SERVER_KEY_PATH = "/api/auth/server-key";
|
private static final String SERVER_KEY_PATH = "/api/auth/server-key";
|
||||||
private static final String SSO_TOKEN_PATH = "/api/auth/sso-token";
|
private static final String SSO_TOKEN_PATH = "/api/auth/sso-token";
|
||||||
|
private static final String CHANGE_PASSWORD_PATH = "/api/auth/change-password";
|
||||||
|
private static final String CHANGE_EMAIL_PATH = "/api/auth/change-email";
|
||||||
|
private static final String CHANGE_USERNAME_PATH = "/api/auth/change-username";
|
||||||
private static final String HEALTH_PATH = "/api/health";
|
private static final String HEALTH_PATH = "/api/health";
|
||||||
|
|
||||||
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
|
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
|
||||||
@@ -64,6 +67,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
&& !path.equals(REFRESH_PATH)
|
&& !path.equals(REFRESH_PATH)
|
||||||
&& !path.equals(SERVER_KEY_PATH)
|
&& !path.equals(SERVER_KEY_PATH)
|
||||||
&& !path.equals(SSO_TOKEN_PATH)
|
&& !path.equals(SSO_TOKEN_PATH)
|
||||||
|
&& !path.equals(CHANGE_PASSWORD_PATH)
|
||||||
|
&& !path.equals(CHANGE_EMAIL_PATH)
|
||||||
|
&& !path.equals(CHANGE_USERNAME_PATH)
|
||||||
&& !path.equals(HEALTH_PATH)) {
|
&& !path.equals(HEALTH_PATH)) {
|
||||||
super.channelRead(ctx, msg);
|
super.channelRead(ctx, msg);
|
||||||
return;
|
return;
|
||||||
@@ -180,6 +186,18 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
handleSsoToken(ctx, req, body, ip);
|
handleSsoToken(ctx, req, body, ip);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (path.equals(CHANGE_PASSWORD_PATH)) {
|
||||||
|
handleChangePassword(ctx, req, body, ip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.equals(CHANGE_EMAIL_PATH)) {
|
||||||
|
handleChangeEmail(ctx, req, body, ip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.equals(CHANGE_USERNAME_PATH)) {
|
||||||
|
handleChangeUsername(ctx, req, body, ip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String turnstileToken = readString(body, "turnstileToken");
|
String turnstileToken = readString(body, "turnstileToken");
|
||||||
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
|
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
|
||||||
@@ -274,6 +292,517 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
sendJson(ctx, req, HttpResponseStatus.OK, res);
|
sendJson(ctx, req, HttpResponseStatus.OK, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleChangePassword(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
|
String authHeader = req.headers().get(HttpHeaderNames.AUTHORIZATION);
|
||||||
|
String bearer = "";
|
||||||
|
if (authHeader != null && authHeader.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||||
|
bearer = authHeader.substring(7).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
int userId = AccessTokenService.verify(bearer);
|
||||||
|
if (userId <= 0) {
|
||||||
|
AuthRateLimiter.recordFailure(ip);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Not authenticated."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String currentPassword = readString(body, "currentPassword");
|
||||||
|
String newPassword = readString(body, "newPassword");
|
||||||
|
String confirmPassword = readString(body, "confirmPassword");
|
||||||
|
|
||||||
|
if (currentPassword.isEmpty() || newPassword.isEmpty() || confirmPassword.isEmpty()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("All fields are required."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPassword.length() > 256 || newPassword.length() > 256) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("Password too long."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPassword.equals(confirmPassword)) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("New passwords do not match."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length() < 8) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("Password must be at least 8 characters."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.equals(currentPassword)) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("New password must be different from the current password."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||||
|
String storedHash = null;
|
||||||
|
String username = null;
|
||||||
|
try (PreparedStatement lookup = conn.prepareStatement(
|
||||||
|
"SELECT username, password FROM users WHERE id = ? LIMIT 1")) {
|
||||||
|
lookup.setInt(1, userId);
|
||||||
|
try (ResultSet rs = lookup.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
username = rs.getString("username");
|
||||||
|
storedHash = rs.getString("password");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedHash == null || storedHash.isEmpty()) {
|
||||||
|
AuthRateLimiter.recordFailure(ip);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Account not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkPassword(currentPassword, storedHash)) {
|
||||||
|
AuthRateLimiter.recordFailure(ip);
|
||||||
|
LOGGER.info("[auth/change-password] current password mismatch for user id={} username='{}'", userId, username);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
|
||||||
|
errorPayload("Current password is incorrect."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String hashed = BCrypt.hashpw(newPassword, BCrypt.gensalt(12));
|
||||||
|
try (PreparedStatement upd = conn.prepareStatement(
|
||||||
|
"UPDATE users SET password = ? WHERE id = ? LIMIT 1")) {
|
||||||
|
upd.setString(1, hashed);
|
||||||
|
upd.setInt(2, userId);
|
||||||
|
upd.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthRateLimiter.recordSuccess(ip);
|
||||||
|
LOGGER.info("[auth/change-password] password updated for user id={} username='{}' ip='{}'", userId, username, ip);
|
||||||
|
|
||||||
|
JsonObject ok = new JsonObject();
|
||||||
|
ok.addProperty("message", "Password updated successfully.");
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("[auth/change-password] failed for user id=" + userId, e);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleChangeEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
|
String authHeader = req.headers().get(HttpHeaderNames.AUTHORIZATION);
|
||||||
|
String bearer = "";
|
||||||
|
if (authHeader != null && authHeader.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||||
|
bearer = authHeader.substring(7).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
int userId = AccessTokenService.verify(bearer);
|
||||||
|
if (userId <= 0) {
|
||||||
|
AuthRateLimiter.recordFailure(ip);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Not authenticated."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String currentPassword = readString(body, "currentPassword");
|
||||||
|
String newEmail = readString(body, "newEmail").trim();
|
||||||
|
|
||||||
|
if (currentPassword.isEmpty() || newEmail.isEmpty()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("All fields are required."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPassword.length() > 256 || newEmail.length() > 254) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("Field too long."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!EMAIL_RE.matcher(newEmail).matches()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("Invalid email address."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||||
|
String storedHash = null;
|
||||||
|
String username = null;
|
||||||
|
String currentEmail = null;
|
||||||
|
try (PreparedStatement lookup = conn.prepareStatement(
|
||||||
|
"SELECT username, password, mail FROM users WHERE id = ? LIMIT 1")) {
|
||||||
|
lookup.setInt(1, userId);
|
||||||
|
try (ResultSet rs = lookup.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
username = rs.getString("username");
|
||||||
|
storedHash = rs.getString("password");
|
||||||
|
currentEmail = rs.getString("mail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedHash == null || storedHash.isEmpty()) {
|
||||||
|
AuthRateLimiter.recordFailure(ip);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Account not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkPassword(currentPassword, storedHash)) {
|
||||||
|
AuthRateLimiter.recordFailure(ip);
|
||||||
|
LOGGER.info("[auth/change-email] password mismatch for user id={} username='{}'", userId, username);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
|
||||||
|
errorPayload("Current password is incorrect."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEmail != null && currentEmail.equalsIgnoreCase(newEmail)) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("New email must be different from the current email."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement check = conn.prepareStatement(
|
||||||
|
"SELECT id FROM users WHERE mail = ? AND id <> ? LIMIT 1")) {
|
||||||
|
check.setString(1, newEmail);
|
||||||
|
check.setInt(2, userId);
|
||||||
|
try (ResultSet rs = check.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.CONFLICT,
|
||||||
|
errorPayload("That email address is already in use."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement upd = conn.prepareStatement(
|
||||||
|
"UPDATE users SET mail = ? WHERE id = ? LIMIT 1")) {
|
||||||
|
upd.setString(1, newEmail);
|
||||||
|
upd.setInt(2, userId);
|
||||||
|
upd.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEmail != null && !currentEmail.isEmpty()) AvailabilityCache.invalidateEmail(currentEmail);
|
||||||
|
AvailabilityCache.invalidateEmail(newEmail);
|
||||||
|
|
||||||
|
AuthRateLimiter.recordSuccess(ip);
|
||||||
|
LOGGER.info("[auth/change-email] email updated for user id={} username='{}' ip='{}'", userId, username, ip);
|
||||||
|
|
||||||
|
JsonObject ok = new JsonObject();
|
||||||
|
ok.addProperty("message", "Email updated successfully.");
|
||||||
|
ok.addProperty("email", newEmail);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("[auth/change-email] failed for user id=" + userId, e);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleChangeUsername(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
|
String authHeader = req.headers().get(HttpHeaderNames.AUTHORIZATION);
|
||||||
|
String bearer = "";
|
||||||
|
if (authHeader != null && authHeader.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||||
|
bearer = authHeader.substring(7).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
int userId = AccessTokenService.verify(bearer);
|
||||||
|
if (userId <= 0) {
|
||||||
|
AuthRateLimiter.recordFailure(ip);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Not authenticated."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String currentPassword = readString(body, "currentPassword");
|
||||||
|
String newUsername = readString(body, "newUsername").trim();
|
||||||
|
|
||||||
|
if (currentPassword.isEmpty() || newUsername.isEmpty()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("All fields are required."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPassword.length() > 256) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("Field too long."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newUsername.length() > 25) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("Username can be at most 25 characters."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!USERNAME_RE.matcher(newUsername).matches()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("Username must be 3-25 characters (letters, numbers, . _ -)."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long cooldownDays = Math.max(0, Emulator.getConfig().getInt("rename.cooldown_days", 30));
|
||||||
|
long cooldownSeconds = cooldownDays * 86400L;
|
||||||
|
|
||||||
|
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||||
|
String storedHash = null;
|
||||||
|
String currentUsername = null;
|
||||||
|
int lastChange = 0;
|
||||||
|
boolean cooldownColumnExists = true;
|
||||||
|
|
||||||
|
try (PreparedStatement lookup = conn.prepareStatement(
|
||||||
|
"SELECT username, password, last_username_change FROM users WHERE id = ? LIMIT 1")) {
|
||||||
|
lookup.setInt(1, userId);
|
||||||
|
try (ResultSet rs = lookup.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
currentUsername = rs.getString("username");
|
||||||
|
storedHash = rs.getString("password");
|
||||||
|
lastChange = rs.getInt("last_username_change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException missingColumn) {
|
||||||
|
cooldownColumnExists = false;
|
||||||
|
LOGGER.warn("[auth/change-username] users.last_username_change column missing — cooldown disabled. Run the migration in config/Database.sql.");
|
||||||
|
try (PreparedStatement lookup = conn.prepareStatement(
|
||||||
|
"SELECT username, password FROM users WHERE id = ? LIMIT 1")) {
|
||||||
|
lookup.setInt(1, userId);
|
||||||
|
try (ResultSet rs = lookup.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
currentUsername = rs.getString("username");
|
||||||
|
storedHash = rs.getString("password");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedHash == null || storedHash.isEmpty()) {
|
||||||
|
AuthRateLimiter.recordFailure(ip);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Account not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkPassword(currentPassword, storedHash)) {
|
||||||
|
AuthRateLimiter.recordFailure(ip);
|
||||||
|
LOGGER.info("[auth/change-username] password mismatch for user id={} username='{}'", userId, currentUsername);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
|
||||||
|
errorPayload("Current password is incorrect."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-only changes (e.g. "bob" -> "Bob") are allowed; identical strings are not.
|
||||||
|
if (currentUsername != null && currentUsername.equals(newUsername)) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("New username must be different from the current username."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int now = Emulator.getIntUnixTimestamp();
|
||||||
|
if (cooldownColumnExists && cooldownSeconds > 0 && lastChange > 0) {
|
||||||
|
long allowedAt = (long) lastChange + cooldownSeconds;
|
||||||
|
if (now < allowedAt) {
|
||||||
|
long remaining = allowedAt - now;
|
||||||
|
long days = remaining / 86400L;
|
||||||
|
long hours = (remaining % 86400L) / 3600L;
|
||||||
|
String wait = days > 0 ? (days + " day" + (days == 1 ? "" : "s")) : (hours + " hour" + (hours == 1 ? "" : "s"));
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||||
|
errorPayload("You can rename again in " + wait + "."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement banned = conn.prepareStatement(
|
||||||
|
"SELECT 1 FROM banned_usernames WHERE LOWER(username) = LOWER(?) LIMIT 1")) {
|
||||||
|
banned.setString(1, newUsername);
|
||||||
|
try (ResultSet rs = banned.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.CONFLICT,
|
||||||
|
errorPayload("That username is not allowed."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException bannedTableError) {
|
||||||
|
// Only swallow "table doesn't exist" — banned_usernames is optional in some installs.
|
||||||
|
// Any other DB error (timeout, syntax, permission, connection drop) must fail the
|
||||||
|
// request so a transient outage can't silently bypass the blocklist.
|
||||||
|
if (bannedTableError.getErrorCode() != 1146
|
||||||
|
&& !"42S02".equals(bannedTableError.getSQLState())) {
|
||||||
|
throw bannedTableError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement check = conn.prepareStatement(
|
||||||
|
"SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id <> ? LIMIT 1")) {
|
||||||
|
check.setString(1, newUsername);
|
||||||
|
check.setInt(2, userId);
|
||||||
|
try (ResultSet rs = check.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.CONFLICT,
|
||||||
|
errorPayload("That username is already taken."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean previousAutoCommit = conn.getAutoCommit();
|
||||||
|
conn.setAutoCommit(false);
|
||||||
|
|
||||||
|
boolean cooldownRace = false;
|
||||||
|
boolean duplicateName = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
int rowsUpdated = 0;
|
||||||
|
|
||||||
|
// Folding the cooldown into the WHERE clause makes the rename atomic against
|
||||||
|
// a second concurrent request with the same token: only one of them will see
|
||||||
|
// rowsUpdated == 1, the other gets 0 and we surface it as 429 instead of letting
|
||||||
|
// both succeed and writing last_username_change twice.
|
||||||
|
try (PreparedStatement upd = conn.prepareStatement(
|
||||||
|
cooldownColumnExists
|
||||||
|
? "UPDATE users SET username = ?, last_username_change = ? "
|
||||||
|
+ "WHERE id = ? "
|
||||||
|
+ " AND (last_username_change = 0 OR last_username_change + ? <= ?) "
|
||||||
|
+ "LIMIT 1"
|
||||||
|
: "UPDATE users SET username = ? WHERE id = ? LIMIT 1")) {
|
||||||
|
upd.setString(1, newUsername);
|
||||||
|
if (cooldownColumnExists) {
|
||||||
|
upd.setInt(2, now);
|
||||||
|
upd.setInt(3, userId);
|
||||||
|
upd.setLong(4, cooldownSeconds);
|
||||||
|
upd.setInt(5, now);
|
||||||
|
} else {
|
||||||
|
upd.setInt(2, userId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rowsUpdated = upd.executeUpdate();
|
||||||
|
} catch (SQLException dup) {
|
||||||
|
// ER_DUP_ENTRY (1062) / SQLState 23000 — the UNIQUE KEY on users.username
|
||||||
|
// raced us between the pre-check and the UPDATE. Surface as 409 instead of
|
||||||
|
// a generic 500 so the UI can show "username taken".
|
||||||
|
if (dup.getErrorCode() == 1062 || "23000".equals(dup.getSQLState())) {
|
||||||
|
duplicateName = true;
|
||||||
|
} else {
|
||||||
|
throw dup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateName || (cooldownColumnExists && rowsUpdated == 0)) {
|
||||||
|
if (!duplicateName) cooldownRace = true;
|
||||||
|
conn.rollback();
|
||||||
|
} else {
|
||||||
|
try (PreparedStatement upd = conn.prepareStatement(
|
||||||
|
"UPDATE rooms SET owner_name = ? WHERE owner_id = ?")) {
|
||||||
|
upd.setString(1, newUsername);
|
||||||
|
upd.setInt(2, userId);
|
||||||
|
upd.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement upd = conn.prepareStatement(
|
||||||
|
"UPDATE rooms_for_sale SET owner_name = ? WHERE user_id = ?")) {
|
||||||
|
upd.setString(1, newUsername);
|
||||||
|
upd.setInt(2, userId);
|
||||||
|
upd.executeUpdate();
|
||||||
|
} catch (SQLException roomsForSale) {
|
||||||
|
// rooms_for_sale is optional — only swallow "table doesn't exist".
|
||||||
|
if (roomsForSale.getErrorCode() != 1146
|
||||||
|
&& !"42S02".equals(roomsForSale.getSQLState())) {
|
||||||
|
throw roomsForSale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.commit();
|
||||||
|
}
|
||||||
|
} catch (SQLException txError) {
|
||||||
|
try { conn.rollback(); } catch (SQLException ignore) {}
|
||||||
|
throw txError;
|
||||||
|
} finally {
|
||||||
|
conn.setAutoCommit(previousAutoCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateName) {
|
||||||
|
LOGGER.info("[auth/change-username] dup-entry race for user id={} wanted='{}'", userId, newUsername);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.CONFLICT,
|
||||||
|
errorPayload("That username is already taken."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cooldownRace) {
|
||||||
|
LOGGER.info("[auth/change-username] cooldown race for user id={} (concurrent rename rejected)", userId);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||||
|
errorPayload("Rename already in progress — please wait."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh in-memory caches BEFORE we drop the client. On disconnect, Habbo.disconnect()
|
||||||
|
// calls HabboInfo.run() which persists every field of HabboInfo back to the users row
|
||||||
|
// (including username) — so if the in-memory object still holds the old name it will
|
||||||
|
// overwrite the DB update we just committed. We also need to re-key the
|
||||||
|
// HabboManager.onlineHabbosByName map and update any loaded Room.ownerName fields so
|
||||||
|
// navigator / room cards stop showing the old name without a server restart.
|
||||||
|
try {
|
||||||
|
if (Emulator.getGameServer() != null && Emulator.getGameServer().getGameClientManager() != null
|
||||||
|
&& Emulator.getGameEnvironment() != null && Emulator.getGameEnvironment().getHabboManager() != null) {
|
||||||
|
com.eu.habbo.habbohotel.users.Habbo habbo =
|
||||||
|
Emulator.getGameServer().getGameClientManager().getHabbo(userId);
|
||||||
|
if (habbo != null) {
|
||||||
|
// Re-key the username map AND update the in-memory username so the
|
||||||
|
// disconnect-time HabboInfo.run() persists the new name, not the old one.
|
||||||
|
Emulator.getGameEnvironment().getHabboManager().removeHabbo(habbo);
|
||||||
|
habbo.getHabboInfo().setUsername(newUsername);
|
||||||
|
Emulator.getGameEnvironment().getHabboManager().addHabbo(habbo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception cacheError) {
|
||||||
|
LOGGER.warn("[auth/change-username] failed to refresh HabboManager cache", cacheError);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Emulator.getGameEnvironment() != null && Emulator.getGameEnvironment().getRoomManager() != null) {
|
||||||
|
for (com.eu.habbo.habbohotel.rooms.Room room : Emulator.getGameEnvironment().getRoomManager().getActiveRooms()) {
|
||||||
|
if (room.getOwnerId() == userId) {
|
||||||
|
room.setOwnerName(newUsername);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception cacheError) {
|
||||||
|
LOGGER.warn("[auth/change-username] failed to refresh Room.ownerName cache", cacheError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marketplace offer-list pages bake the seller name into the serialized payload,
|
||||||
|
// so invalidate it; the next /offers request will rebuild it with the new name.
|
||||||
|
try {
|
||||||
|
com.eu.habbo.messages.incoming.catalog.marketplace.RequestOffersEvent.cachedResults.clear();
|
||||||
|
} catch (Exception cacheError) {
|
||||||
|
LOGGER.warn("[auth/change-username] failed to clear marketplace cache", cacheError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop any active session so the user reconnects with the fresh identity
|
||||||
|
// (friend list, navigator, messenger threads all refresh on relogin).
|
||||||
|
try (PreparedStatement clear = conn.prepareStatement(
|
||||||
|
"UPDATE users SET auth_ticket = '', online = '0' WHERE id = ? LIMIT 1")) {
|
||||||
|
clear.setInt(1, userId);
|
||||||
|
clear.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Emulator.getGameServer() != null
|
||||||
|
&& Emulator.getGameServer().getGameClientManager() != null) {
|
||||||
|
com.eu.habbo.habbohotel.users.Habbo habbo =
|
||||||
|
Emulator.getGameServer().getGameClientManager().getHabbo(userId);
|
||||||
|
if (habbo != null && habbo.getClient() != null) {
|
||||||
|
Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthRateLimiter.recordSuccess(ip);
|
||||||
|
LOGGER.info("[auth/change-username] '{}' -> '{}' (user id={}, ip='{}')",
|
||||||
|
currentUsername, newUsername, userId, ip);
|
||||||
|
|
||||||
|
JsonObject ok = new JsonObject();
|
||||||
|
ok.addProperty("message", "Username updated. Please log in again with your new name.");
|
||||||
|
ok.addProperty("username", newUsername);
|
||||||
|
ok.addProperty("relogin", true);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("[auth/change-username] failed for user id=" + userId, e);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) {
|
private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) {
|
||||||
String ssoTicket = readString(body, "ssoTicket");
|
String ssoTicket = readString(body, "ssoTicket");
|
||||||
String rememberToken = readString(body, "rememberToken").trim();
|
String rememberToken = readString(body, "rememberToken").trim();
|
||||||
@@ -1135,11 +1664,20 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
||||||
if (origin != null && !origin.isEmpty()) {
|
if (origin != null && !origin.isEmpty()) {
|
||||||
response.headers().set("Access-Control-Allow-Origin", origin);
|
response.headers().set("Access-Control-Allow-Origin", origin);
|
||||||
response.headers().set("Vary", "Origin");
|
|
||||||
response.headers().set("Access-Control-Allow-Credentials", "true");
|
response.headers().set("Access-Control-Allow-Credentials", "true");
|
||||||
}
|
}
|
||||||
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
||||||
response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
|
|
||||||
|
String requestedHeaders = req.headers().get("Access-Control-Request-Headers");
|
||||||
|
if (requestedHeaders != null && !requestedHeaders.isEmpty()) {
|
||||||
|
response.headers().set("Access-Control-Allow-Headers", requestedHeaders);
|
||||||
|
} else {
|
||||||
|
response.headers().set("Access-Control-Allow-Headers",
|
||||||
|
"Authorization, Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
|
||||||
|
}
|
||||||
|
|
||||||
|
response.headers().set("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method");
|
||||||
|
response.headers().set("Access-Control-Max-Age", "600");
|
||||||
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
|
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -267,11 +267,20 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler {
|
|||||||
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
||||||
if (origin != null && !origin.isEmpty()) {
|
if (origin != null && !origin.isEmpty()) {
|
||||||
response.headers().set("Access-Control-Allow-Origin", origin);
|
response.headers().set("Access-Control-Allow-Origin", origin);
|
||||||
response.headers().set("Vary", "Origin");
|
|
||||||
response.headers().set("Access-Control-Allow-Credentials", "true");
|
response.headers().set("Access-Control-Allow-Credentials", "true");
|
||||||
}
|
}
|
||||||
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
||||||
response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
|
|
||||||
|
String requestedHeaders = req.headers().get("Access-Control-Request-Headers");
|
||||||
|
if (requestedHeaders != null && !requestedHeaders.isEmpty()) {
|
||||||
|
response.headers().set("Access-Control-Allow-Headers", requestedHeaders);
|
||||||
|
} else {
|
||||||
|
response.headers().set("Access-Control-Allow-Headers",
|
||||||
|
"Authorization, Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
|
||||||
|
}
|
||||||
|
|
||||||
|
response.headers().set("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method");
|
||||||
|
response.headers().set("Access-Control-Max-Age", "600");
|
||||||
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
|
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -299,10 +299,18 @@ public class NitroSecureAssetHandler extends ChannelInboundHandlerAdapter {
|
|||||||
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
||||||
if (origin != null && !origin.isEmpty()) {
|
if (origin != null && !origin.isEmpty()) {
|
||||||
response.headers().set("Access-Control-Allow-Origin", origin);
|
response.headers().set("Access-Control-Allow-Origin", origin);
|
||||||
response.headers().set("Vary", "Origin");
|
|
||||||
}
|
}
|
||||||
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
||||||
response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Nitro-Key");
|
|
||||||
|
String requestedHeaders = req.headers().get("Access-Control-Request-Headers");
|
||||||
|
if (requestedHeaders != null && !requestedHeaders.isEmpty()) {
|
||||||
|
response.headers().set("Access-Control-Allow-Headers", requestedHeaders);
|
||||||
|
} else {
|
||||||
|
response.headers().set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Nitro-Key");
|
||||||
|
}
|
||||||
|
|
||||||
|
response.headers().set("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method");
|
||||||
|
response.headers().set("Access-Control-Max-Age", "600");
|
||||||
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
|
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user