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
🆙 Refactor AuthHttpHandler for the API and Websocket
This commit is contained in:
+503
@@ -0,0 +1,503 @@
|
||||
package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpResponseStatus;
|
||||
import org.mindrot.jbcrypt.BCrypt;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.EMAIL_RE;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.USERNAME_RE;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.checkPassword;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readString;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson;
|
||||
|
||||
final class AccountChangeEndpoints {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(AccountChangeEndpoints.class);
|
||||
|
||||
private AccountChangeEndpoints() {
|
||||
}
|
||||
|
||||
static void handleChangePassword(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
int userId = verifyBearer(req, ip, ctx);
|
||||
if (userId <= 0) 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."));
|
||||
}
|
||||
}
|
||||
|
||||
static void handleChangeEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
int userId = verifyBearer(req, ip, ctx);
|
||||
if (userId <= 0) 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."));
|
||||
}
|
||||
}
|
||||
|
||||
static void handleChangeUsername(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
int userId = verifyBearer(req, ip, ctx);
|
||||
if (userId <= 0) 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 static int verifyBearer(FullHttpRequest req, String ip, ChannelHandlerContext ctx) {
|
||||
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 userId;
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpResponseStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.EMAIL_RE;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.USERNAME_RE;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readString;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson;
|
||||
|
||||
final class AccountCheckEndpoints {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(AccountCheckEndpoints.class);
|
||||
|
||||
private AccountCheckEndpoints() {
|
||||
}
|
||||
|
||||
static void handleCheckEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
if (!AuthRateLimiter.tryProbe(ip)) {
|
||||
long secs = AuthRateLimiter.secondsUntilProbeReset(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||
errorPayload("Too many requests. Try again in " + secs + "s."));
|
||||
return;
|
||||
}
|
||||
String email = readString(body, "email").trim();
|
||||
if (email.isEmpty() || email.length() > 254 || !EMAIL_RE.matcher(email).matches()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Invalid email address."));
|
||||
return;
|
||||
}
|
||||
|
||||
Boolean cached = AvailabilityCache.lookupEmail(email);
|
||||
boolean taken;
|
||||
if (cached != null) {
|
||||
taken = !cached;
|
||||
} else {
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT 1 FROM users WHERE mail = ? LIMIT 1")) {
|
||||
stmt.setString(1, email);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
taken = rs.next();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("check-email failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
return;
|
||||
}
|
||||
AvailabilityCache.storeEmail(email, !taken);
|
||||
}
|
||||
|
||||
JsonObject res = new JsonObject();
|
||||
res.addProperty("available", !taken);
|
||||
if (taken) res.addProperty("error", "This email is already in use.");
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, res);
|
||||
}
|
||||
|
||||
static void handleCheckUsername(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
if (!AuthRateLimiter.tryProbe(ip)) {
|
||||
long secs = AuthRateLimiter.secondsUntilProbeReset(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||
errorPayload("Too many requests. Try again in " + secs + "s."));
|
||||
return;
|
||||
}
|
||||
String username = readString(body, "username").trim();
|
||||
if (!USERNAME_RE.matcher(username).matches()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||
errorPayload("Username must be 3-32 chars (letters, numbers, . _ -)."));
|
||||
return;
|
||||
}
|
||||
|
||||
Boolean cached = AvailabilityCache.lookupUsername(username);
|
||||
boolean taken;
|
||||
if (cached != null) {
|
||||
taken = !cached;
|
||||
} else {
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT 1 FROM users WHERE username = ? LIMIT 1")) {
|
||||
stmt.setString(1, username);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
taken = rs.next();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("check-username failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
return;
|
||||
}
|
||||
AvailabilityCache.storeUsername(username, !taken);
|
||||
}
|
||||
|
||||
JsonObject res = new JsonObject();
|
||||
res.addProperty("available", !taken);
|
||||
if (taken) res.addProperty("error", "This Habbo name is already taken.");
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, res);
|
||||
}
|
||||
}
|
||||
+68
-1569
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.networking.gameserver.GameServerAttributes;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.http.DefaultFullHttpResponse;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.FullHttpResponse;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpHeaderValues;
|
||||
import io.netty.handler.codec.http.HttpResponseStatus;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import org.mindrot.jbcrypt.BCrypt;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class AuthHttpUtil {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpUtil.class);
|
||||
|
||||
static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
|
||||
static final Pattern EMAIL_RE = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
|
||||
static final Pattern FIGURE_RE = Pattern.compile("^[A-Za-z0-9.\\-]{1,200}$");
|
||||
|
||||
static final SecureRandom RNG = new SecureRandom();
|
||||
static final int MAX_BODY_BYTES = 8 * 1024;
|
||||
|
||||
private static final long PERMANENT_BAN_THRESHOLD_SECONDS = 30L * 365L * 24L * 60L * 60L;
|
||||
|
||||
private AuthHttpUtil() {
|
||||
}
|
||||
|
||||
static String readString(JsonObject obj, String key) {
|
||||
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return "";
|
||||
try {
|
||||
return obj.get(key).getAsString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
static int readInt(JsonObject obj, String key, int defaultValue) {
|
||||
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
||||
try {
|
||||
return obj.get(key).getAsInt();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean readBoolean(JsonObject obj, String key, boolean defaultValue) {
|
||||
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
||||
try {
|
||||
JsonElement el = obj.get(key);
|
||||
if (el.getAsJsonPrimitive().isBoolean()) return el.getAsBoolean();
|
||||
String s = el.getAsString();
|
||||
return "1".equals(s) || "true".equalsIgnoreCase(s);
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
static JsonObject errorPayload(String message) {
|
||||
JsonObject obj = new JsonObject();
|
||||
obj.addProperty("error", message);
|
||||
return obj;
|
||||
}
|
||||
|
||||
static void sendJson(ChannelHandlerContext ctx, FullHttpRequest req,
|
||||
HttpResponseStatus status, JsonObject body) {
|
||||
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
|
||||
FullHttpResponse response = new DefaultFullHttpResponse(
|
||||
HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes));
|
||||
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
|
||||
response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
|
||||
applyCors(req, response);
|
||||
boolean keepAlive = isKeepAlive(req);
|
||||
if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
|
||||
var future = ctx.writeAndFlush(response);
|
||||
if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE);
|
||||
}
|
||||
|
||||
static void sendCors(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||
FullHttpResponse response = new DefaultFullHttpResponse(
|
||||
HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT);
|
||||
applyCors(req, response);
|
||||
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
|
||||
}
|
||||
|
||||
static void applyCors(FullHttpRequest req, FullHttpResponse response) {
|
||||
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
||||
|
||||
if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) {
|
||||
response.headers().set("Access-Control-Allow-Origin", origin);
|
||||
response.headers().set("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
static boolean isKeepAlive(FullHttpRequest req) {
|
||||
String connection = req.headers().get(HttpHeaderNames.CONNECTION);
|
||||
return connection == null || !"close".equalsIgnoreCase(connection);
|
||||
}
|
||||
|
||||
static String resolveClientIp(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||
String ipHeader = Emulator.getConfig() != null
|
||||
? Emulator.getConfig().getValue("ws.ip.header", "")
|
||||
: "";
|
||||
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) {
|
||||
String hv = req.headers().get(ipHeader);
|
||||
if (hv != null && !hv.isEmpty()) {
|
||||
int comma = hv.indexOf(',');
|
||||
return (comma > 0 ? hv.substring(0, comma) : hv).trim();
|
||||
}
|
||||
}
|
||||
if (ctx.channel().attr(GameServerAttributes.WS_IP).get() != null) {
|
||||
return ctx.channel().attr(GameServerAttributes.WS_IP).get();
|
||||
}
|
||||
if (ctx.channel().remoteAddress() instanceof InetSocketAddress addr) {
|
||||
return addr.getAddress().getHostAddress();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static boolean checkPassword(String plain, String stored) {
|
||||
String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored;
|
||||
try {
|
||||
return BCrypt.checkpw(plain, compatible);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static String mintSsoTicket() {
|
||||
byte[] buf = new byte[32];
|
||||
RNG.nextBytes(buf);
|
||||
return "nitro-" + Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
static String mintResetToken() {
|
||||
byte[] buf = new byte[32];
|
||||
RNG.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
static final class BanInfo {
|
||||
final String type;
|
||||
final String reason;
|
||||
final int expiresAt;
|
||||
|
||||
BanInfo(String type, String reason, int expiresAt) {
|
||||
this.type = type == null ? "account" : type;
|
||||
this.reason = reason == null ? "" : reason;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
boolean isPermanent() {
|
||||
return (long) expiresAt - Emulator.getIntUnixTimestamp() > PERMANENT_BAN_THRESHOLD_SECONDS;
|
||||
}
|
||||
}
|
||||
|
||||
static BanInfo lookupAccountBan(Connection conn, int userId) throws SQLException {
|
||||
try (PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT ban_expire, ban_reason, type FROM bans " +
|
||||
"WHERE user_id = ? AND ban_expire >= ? AND (type = 'account' OR type = 'super') " +
|
||||
"ORDER BY ban_expire DESC LIMIT 1")) {
|
||||
stmt.setInt(1, userId);
|
||||
stmt.setInt(2, Emulator.getIntUnixTimestamp());
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return new BanInfo(rs.getString("type"), rs.getString("ban_reason"), rs.getInt("ban_expire"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static BanInfo lookupIpBan(Connection conn, String ip) throws SQLException {
|
||||
try (PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT ban_expire, ban_reason, type FROM bans " +
|
||||
"WHERE ip = ? AND ban_expire >= ? AND (type = 'ip' OR type = 'super') " +
|
||||
"ORDER BY ban_expire DESC LIMIT 1")) {
|
||||
stmt.setString(1, ip);
|
||||
stmt.setInt(2, Emulator.getIntUnixTimestamp());
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return new BanInfo(rs.getString("type"), rs.getString("ban_reason"), rs.getInt("ban_expire"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static JsonObject bannedPayload(BanInfo ban) {
|
||||
boolean permanent = ban.isPermanent();
|
||||
String message = permanent
|
||||
? "Your account has been permanently banned."
|
||||
: "Your account is temporarily banned.";
|
||||
|
||||
JsonObject details = new JsonObject();
|
||||
details.addProperty("type", ban.type);
|
||||
details.addProperty("reason", ban.reason);
|
||||
details.addProperty("permanent", permanent);
|
||||
if (!permanent) details.addProperty("expiresAt", ban.expiresAt);
|
||||
|
||||
JsonObject obj = new JsonObject();
|
||||
obj.addProperty("error", message);
|
||||
obj.add("ban", details);
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
public final class CorsOriginGate {
|
||||
|
||||
private static final String CONFIG_KEY = "ws.whitelist";
|
||||
private static final String CONFIG_DEFAULT = "localhost";
|
||||
|
||||
private CorsOriginGate() {
|
||||
}
|
||||
|
||||
public static boolean isAllowed(FullHttpRequest req) {
|
||||
if (req == null) return false;
|
||||
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
||||
if (origin == null || origin.isEmpty()) return false;
|
||||
|
||||
String host;
|
||||
try {
|
||||
URI uri = new URI(origin);
|
||||
host = uri.getHost();
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
if (host == null || host.isEmpty()) return false;
|
||||
if (host.startsWith("www.")) host = host.substring(4);
|
||||
|
||||
String configured = Emulator.getConfig().getValue(CONFIG_KEY, CONFIG_DEFAULT);
|
||||
if (configured == null || configured.isEmpty()) return false;
|
||||
|
||||
for (String entry : configured.split(",")) {
|
||||
String trimmed = entry.trim();
|
||||
if (trimmed.isEmpty()) continue;
|
||||
|
||||
if ("*".equals(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (trimmed.startsWith("*")) {
|
||||
String suffix = trimmed.substring(1);
|
||||
if (host.endsWith(suffix) || ("." + host).equals(suffix)) {
|
||||
return true;
|
||||
}
|
||||
} else if (host.equals(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -265,7 +265,8 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler {
|
||||
|
||||
private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
|
||||
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
||||
if (origin != null && !origin.isEmpty()) {
|
||||
|
||||
if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) {
|
||||
response.headers().set("Access-Control-Allow-Origin", origin);
|
||||
response.headers().set("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
|
||||
+2
-1
@@ -297,7 +297,8 @@ public class NitroSecureAssetHandler extends ChannelInboundHandlerAdapter {
|
||||
|
||||
private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
|
||||
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
||||
if (origin != null && !origin.isEmpty()) {
|
||||
|
||||
if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) {
|
||||
response.headers().set("Access-Control-Allow-Origin", origin);
|
||||
}
|
||||
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
final class RegistrationSupport {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(RegistrationSupport.class);
|
||||
|
||||
private RegistrationSupport() {
|
||||
}
|
||||
|
||||
static void materializeCustomLayout(Connection conn, int templateId, int newRoomId) {
|
||||
String overrideModel = "0";
|
||||
String heightmap = "";
|
||||
int doorX = 0, doorY = 0, doorDir = 2;
|
||||
try (PreparedStatement sel = conn.prepareStatement(
|
||||
"SELECT override_model, heightmap, door_x, door_y, door_dir " +
|
||||
"FROM room_templates WHERE template_id = ? LIMIT 1")) {
|
||||
sel.setInt(1, templateId);
|
||||
try (ResultSet rs = sel.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
overrideModel = rs.getString("override_model");
|
||||
heightmap = rs.getString("heightmap");
|
||||
doorX = rs.getInt("door_x");
|
||||
doorY = rs.getInt("door_y");
|
||||
doorDir = rs.getInt("door_dir");
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("[auth/register] reading template layout failed templateId=" + templateId, e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!"1".equals(overrideModel) || heightmap == null || heightmap.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String customName = "custom_" + newRoomId;
|
||||
|
||||
try (PreparedStatement ins = conn.prepareStatement(
|
||||
"INSERT INTO room_models_custom (id, name, door_x, door_y, door_dir, heightmap) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?) " +
|
||||
"ON DUPLICATE KEY UPDATE name = VALUES(name), door_x = VALUES(door_x), " +
|
||||
"door_y = VALUES(door_y), door_dir = VALUES(door_dir), heightmap = VALUES(heightmap)")) {
|
||||
ins.setInt(1, newRoomId);
|
||||
ins.setString(2, customName);
|
||||
ins.setInt(3, doorX);
|
||||
ins.setInt(4, doorY);
|
||||
ins.setInt(5, doorDir);
|
||||
ins.setString(6, heightmap);
|
||||
ins.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("[auth/register] room_models_custom insert failed roomId=" + newRoomId, e);
|
||||
return;
|
||||
}
|
||||
|
||||
try (PreparedStatement upd = conn.prepareStatement(
|
||||
"UPDATE rooms SET model = ? WHERE id = ? LIMIT 1")) {
|
||||
upd.setString(1, customName);
|
||||
upd.setInt(2, newRoomId);
|
||||
upd.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("[auth/register] rooms.model rename failed roomId=" + newRoomId, e);
|
||||
}
|
||||
|
||||
LOGGER.info("[auth/register] materialized custom layout '{}' for roomId={}", customName, newRoomId);
|
||||
}
|
||||
|
||||
static void seedUserCurrencies(Connection conn, int userId, int duckets, int diamonds) {
|
||||
try (PreparedStatement ins = conn.prepareStatement(
|
||||
"INSERT INTO users_currency (user_id, type, amount) VALUES (?, ?, ?) " +
|
||||
"ON DUPLICATE KEY UPDATE amount = VALUES(amount)")) {
|
||||
if (duckets > 0) {
|
||||
ins.setInt(1, userId);
|
||||
ins.setInt(2, 0);
|
||||
ins.setInt(3, duckets);
|
||||
ins.addBatch();
|
||||
}
|
||||
if (diamonds > 0) {
|
||||
ins.setInt(1, userId);
|
||||
ins.setInt(2, 5);
|
||||
ins.setInt(3, diamonds);
|
||||
ins.addBatch();
|
||||
}
|
||||
ins.executeBatch();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("[auth/register] seeding users_currency failed userId=" + userId
|
||||
+ " duckets=" + duckets + " diamonds=" + diamonds, e);
|
||||
}
|
||||
}
|
||||
|
||||
static void cloneTemplateForUser(Connection conn, int templateId, int userId, String userName) {
|
||||
LOGGER.info("[auth/register] cloning template id={} for user id={} name='{}'", templateId, userId, userName);
|
||||
|
||||
try (PreparedStatement check = conn.prepareStatement(
|
||||
"SELECT 1 FROM room_templates WHERE template_id = ? AND enabled = '1' LIMIT 1")) {
|
||||
check.setInt(1, templateId);
|
||||
try (ResultSet rs = check.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
LOGGER.warn("[auth/register] unknown/disabled room template id={} for user id={}", templateId, userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("[auth/register] template lookup failed for templateId=" + templateId, e);
|
||||
return;
|
||||
}
|
||||
|
||||
int newRoomId = 0;
|
||||
int roomsInserted = 0;
|
||||
try (PreparedStatement ins = conn.prepareStatement(
|
||||
"INSERT INTO rooms (owner_id, owner_name, name, description, model, password, state, " +
|
||||
"users_max, category, paper_floor, paper_wall, paper_landscape, thickness_wall, " +
|
||||
"thickness_floor, moodlight_data, override_model, trade_mode) " +
|
||||
"(SELECT ?, ?, name, room_description, model, password, state, " +
|
||||
"users_max, category, paper_floor, paper_wall, paper_landscape, thickness_wall, " +
|
||||
"thickness_floor, moodlight_data, override_model, trade_mode " +
|
||||
"FROM room_templates WHERE template_id = ?)",
|
||||
Statement.RETURN_GENERATED_KEYS)) {
|
||||
ins.setInt(1, userId);
|
||||
ins.setString(2, userName);
|
||||
ins.setInt(3, templateId);
|
||||
roomsInserted = ins.executeUpdate();
|
||||
try (ResultSet keys = ins.getGeneratedKeys()) {
|
||||
if (keys.next()) newRoomId = keys.getInt(1);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("[auth/register] clone rooms failed templateId=" + templateId + " userId=" + userId, e);
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.info("[auth/register] rooms insert: rowsAffected={} newRoomId={}", roomsInserted, newRoomId);
|
||||
|
||||
if (newRoomId <= 0) {
|
||||
LOGGER.warn("[auth/register] clone aborted - no roomId returned (templateId={}, userId={})", templateId, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
materializeCustomLayout(conn, templateId, newRoomId);
|
||||
|
||||
int itemsInserted = 0;
|
||||
try (PreparedStatement ins = conn.prepareStatement(
|
||||
"INSERT INTO items (user_id, room_id, item_id, wall_pos, x, y, z, rot, " +
|
||||
"extra_data, wired_data, limited_data, guild_id) " +
|
||||
"(SELECT ?, ?, item_id, wall_pos, x, y, z, rot, extra_data, wired_data, '0:0', 0 " +
|
||||
"FROM room_templates_items WHERE template_id = ?)")) {
|
||||
ins.setInt(1, userId);
|
||||
ins.setInt(2, newRoomId);
|
||||
ins.setInt(3, templateId);
|
||||
itemsInserted = ins.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("[auth/register] clone items failed templateId=" + templateId
|
||||
+ " roomId=" + newRoomId + " userId=" + userId, e);
|
||||
}
|
||||
|
||||
LOGGER.info("[auth/register] items insert: rowsAffected={} roomId={}", itemsInserted, newRoomId);
|
||||
|
||||
try (PreparedStatement upd = conn.prepareStatement(
|
||||
"UPDATE users SET home_room = ? WHERE id = ? LIMIT 1")) {
|
||||
upd.setInt(1, newRoomId);
|
||||
upd.setInt(2, userId);
|
||||
int rows = upd.executeUpdate();
|
||||
LOGGER.info("[auth/register] home_room update: rowsAffected={} userId={} roomId={}", rows, userId, newRoomId);
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("[auth/register] setting home_room failed userId=" + userId + " roomId=" + newRoomId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpResponseStatus;
|
||||
import org.mindrot.jbcrypt.BCrypt;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.EMAIL_RE;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.FIGURE_RE;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.USERNAME_RE;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.bannedPayload;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.checkPassword;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.lookupAccountBan;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.lookupIpBan;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.mintResetToken;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.mintSsoTicket;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readBoolean;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readInt;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readString;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson;
|
||||
|
||||
final class SessionEndpoints {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(SessionEndpoints.class);
|
||||
|
||||
private SessionEndpoints() {
|
||||
}
|
||||
|
||||
static void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body) {
|
||||
String ssoTicket = readString(body, "ssoTicket");
|
||||
String rememberToken = readString(body, "rememberToken").trim();
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("message", "Logged out.");
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||
int userId = 0;
|
||||
|
||||
if (ssoTicket != null && !ssoTicket.isEmpty()) {
|
||||
try (PreparedStatement lookup = conn.prepareStatement(
|
||||
"SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
|
||||
lookup.setString(1, ssoTicket);
|
||||
try (ResultSet rs = lookup.executeQuery()) {
|
||||
if (rs.next()) userId = rs.getInt("id");
|
||||
}
|
||||
}
|
||||
|
||||
if (userId > 0) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rememberToken.isEmpty()) {
|
||||
RememberJwtService.revokeFromToken(conn, rememberToken);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Logout cleanup failed", e);
|
||||
}
|
||||
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
}
|
||||
|
||||
static void handleRemember(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
String jwt = readString(body, "rememberToken").trim();
|
||||
if (jwt.isEmpty()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken."));
|
||||
return;
|
||||
}
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||
RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip);
|
||||
if (rot == null) {
|
||||
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired."));
|
||||
return;
|
||||
}
|
||||
|
||||
String ssoTicket = mintSsoTicket();
|
||||
try (PreparedStatement upd = conn.prepareStatement(
|
||||
"UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) {
|
||||
upd.setString(1, ssoTicket);
|
||||
upd.setString(2, ip == null ? "" : ip);
|
||||
upd.setInt(3, rot.userId);
|
||||
upd.executeUpdate();
|
||||
}
|
||||
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("ssoTicket", ssoTicket);
|
||||
ok.addProperty("username", rot.username);
|
||||
ok.addProperty("rememberToken", rot.jwt);
|
||||
ok.addProperty("expiresAt", rot.expiresAt);
|
||||
ok.addProperty("rememberExpiresAt", rot.expiresAt);
|
||||
AccessTokenService.Issued access = AccessTokenService.issue(rot.userId);
|
||||
ok.addProperty("accessToken", access.token);
|
||||
ok.addProperty("accessTokenExpiresAt", access.expiresAt);
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Remember login failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
}
|
||||
}
|
||||
|
||||
static void handleSsoToken(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
String ssoTicket = readString(body, "ssoTicket").trim();
|
||||
if (ssoTicket.isEmpty() || ssoTicket.length() > 128) {
|
||||
AuthRateLimiter.recordFailure(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing or invalid ssoTicket."));
|
||||
return;
|
||||
}
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement lookup = conn.prepareStatement(
|
||||
"SELECT id, username FROM users WHERE auth_ticket = ? LIMIT 1")) {
|
||||
lookup.setString(1, ssoTicket);
|
||||
try (ResultSet rs = lookup.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
AuthRateLimiter.recordFailure(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("SSO ticket not recognised."));
|
||||
return;
|
||||
}
|
||||
int userId = rs.getInt("id");
|
||||
String username = rs.getString("username");
|
||||
|
||||
AuthRateLimiter.recordSuccess(ip);
|
||||
|
||||
AccessTokenService.Issued access = AccessTokenService.issue(userId);
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("username", username);
|
||||
ok.addProperty("accessToken", access.token);
|
||||
ok.addProperty("accessTokenExpiresAt", access.expiresAt);
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[auth/sso-token] lookup failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
}
|
||||
}
|
||||
|
||||
static void handleRefresh(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
String jwt = readString(body, "rememberToken").trim();
|
||||
if (jwt.isEmpty()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken."));
|
||||
return;
|
||||
}
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||
RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip);
|
||||
if (rot == null) {
|
||||
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired."));
|
||||
return;
|
||||
}
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("rememberToken", rot.jwt);
|
||||
ok.addProperty("expiresAt", rot.expiresAt);
|
||||
ok.addProperty("rememberExpiresAt", rot.expiresAt);
|
||||
AccessTokenService.Issued access = AccessTokenService.issue(rot.userId);
|
||||
ok.addProperty("accessToken", access.token);
|
||||
ok.addProperty("accessTokenExpiresAt", access.expiresAt);
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Refresh failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
}
|
||||
}
|
||||
|
||||
static void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
String username = readString(body, "username").trim();
|
||||
String password = readString(body, "password");
|
||||
boolean rememberMe = readBoolean(body, "remember", false) || readBoolean(body, "rememberMe", false);
|
||||
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing credentials."));
|
||||
return;
|
||||
}
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||
if (ip != null && !ip.isEmpty()) {
|
||||
AuthHttpUtil.BanInfo ipBan = lookupIpBan(conn, ip);
|
||||
if (ipBan != null) {
|
||||
LOGGER.info("[auth/login] ip ban hit ip={} type={} expires={}",
|
||||
ip, ipBan.type, ipBan.expiresAt);
|
||||
sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, bannedPayload(ipBan));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT id, username, password FROM users WHERE username = ? LIMIT 1")) {
|
||||
stmt.setString(1, username);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
LOGGER.info("[auth/login] user not found username='{}' ip={}", username, ip);
|
||||
AuthRateLimiter.recordFailure(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
|
||||
errorPayload("Invalid Habbo name or password."));
|
||||
return;
|
||||
}
|
||||
|
||||
int userId = rs.getInt("id");
|
||||
String stored = rs.getString("password");
|
||||
String storedPreview = stored == null
|
||||
? "<null>"
|
||||
: (stored.isEmpty() ? "<empty>" : stored.substring(0, Math.min(7, stored.length())) + "…(" + stored.length() + " chars)");
|
||||
|
||||
if (stored == null || stored.isEmpty() || !checkPassword(password, stored)) {
|
||||
LOGGER.info("[auth/login] password mismatch for user id={} username='{}' stored='{}'",
|
||||
userId, username, storedPreview);
|
||||
AuthRateLimiter.recordFailure(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
|
||||
errorPayload("Invalid Habbo name or password."));
|
||||
return;
|
||||
}
|
||||
|
||||
AuthHttpUtil.BanInfo accountBan = lookupAccountBan(conn, userId);
|
||||
if (accountBan != null) {
|
||||
LOGGER.info("[auth/login] account ban hit userId={} type={} expires={}",
|
||||
userId, accountBan.type, accountBan.expiresAt);
|
||||
AuthRateLimiter.recordSuccess(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, bannedPayload(accountBan));
|
||||
return;
|
||||
}
|
||||
|
||||
String ssoTicket = mintSsoTicket();
|
||||
|
||||
try (PreparedStatement upd = conn.prepareStatement(
|
||||
"UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) {
|
||||
upd.setString(1, ssoTicket);
|
||||
upd.setString(2, ip == null ? "" : ip);
|
||||
upd.setInt(3, userId);
|
||||
upd.executeUpdate();
|
||||
}
|
||||
|
||||
String rememberToken = null;
|
||||
if (rememberMe) {
|
||||
try {
|
||||
RememberJwtService.RotationResult issued = RememberJwtService.issueForNewFamily(
|
||||
conn, userId, rs.getString("username"), ip);
|
||||
rememberToken = issued.jwt;
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Failed to issue remember-me JWT for userId=" + userId, e);
|
||||
}
|
||||
}
|
||||
|
||||
AuthRateLimiter.recordSuccess(ip);
|
||||
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("ssoTicket", ssoTicket);
|
||||
ok.addProperty("username", rs.getString("username"));
|
||||
if (rememberToken != null) ok.addProperty("rememberToken", rememberToken);
|
||||
AccessTokenService.Issued access = AccessTokenService.issue(userId);
|
||||
ok.addProperty("accessToken", access.token);
|
||||
ok.addProperty("accessTokenExpiresAt", access.expiresAt);
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Login query failed for username=" + username, e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
}
|
||||
}
|
||||
|
||||
static void handleRegister(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
if (!Emulator.getConfig().getBoolean("login.register.enabled", true)) {
|
||||
sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, errorPayload("Registration is closed."));
|
||||
return;
|
||||
}
|
||||
|
||||
String username = readString(body, "username").trim();
|
||||
String email = readString(body, "email").trim();
|
||||
String password = readString(body, "password");
|
||||
String figure = readString(body, "figure").trim();
|
||||
String gender = readString(body, "gender").trim().toUpperCase();
|
||||
int templateId = readInt(body, "templateId", 0);
|
||||
|
||||
if (!USERNAME_RE.matcher(username).matches()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||
errorPayload("Username must be 3-32 chars (letters, numbers, . _ -)."));
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_RE.matcher(email).matches()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Invalid email address."));
|
||||
return;
|
||||
}
|
||||
if (password.length() < 8) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||
errorPayload("Password must be at least 8 characters."));
|
||||
return;
|
||||
}
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||
int maxPerIp = Emulator.getConfig().getInt("register.max_per_ip", 5);
|
||||
if (maxPerIp > 0 && ip != null && !ip.isEmpty()) {
|
||||
try (PreparedStatement quota = conn.prepareStatement(
|
||||
"SELECT COUNT(*) FROM users WHERE ip_register = ?")) {
|
||||
quota.setString(1, ip);
|
||||
try (ResultSet rs = quota.executeQuery()) {
|
||||
if (rs.next() && rs.getInt(1) >= maxPerIp) {
|
||||
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||
errorPayload("This IP has reached the maximum of "
|
||||
+ maxPerIp + " registered accounts."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement check = conn.prepareStatement(
|
||||
"SELECT username, mail FROM users WHERE username = ? OR mail = ? LIMIT 1")) {
|
||||
check.setString(1, username);
|
||||
check.setString(2, email);
|
||||
try (ResultSet rs = check.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
String existingUser = rs.getString("username");
|
||||
String existingMail = rs.getString("mail");
|
||||
boolean userTaken = existingUser != null && existingUser.equalsIgnoreCase(username);
|
||||
boolean mailTaken = existingMail != null && existingMail.equalsIgnoreCase(email);
|
||||
String message;
|
||||
if (userTaken && mailTaken) message = "That Habbo name and email are already in use.";
|
||||
else if (userTaken) message = "That Habbo name is already in use.";
|
||||
else message = "That email address is already in use.";
|
||||
sendJson(ctx, req, HttpResponseStatus.CONFLICT, errorPayload(message));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String hashed = BCrypt.hashpw(password, BCrypt.gensalt(12));
|
||||
String defaultLook = Emulator.getConfig().getValue("register.default.look",
|
||||
"hr-100-7.hd-180-1.ch-210-66.lg-270-82.sh-290-80");
|
||||
String defaultMotto = Emulator.getConfig().getValue("register.default.motto", "I love Habbo!");
|
||||
int now = Emulator.getIntUnixTimestamp();
|
||||
|
||||
String finalLook = (figure.isEmpty() || !FIGURE_RE.matcher(figure).matches()) ? defaultLook : figure;
|
||||
String finalGender = (gender.equals("M") || gender.equals("F")) ? gender : "M";
|
||||
|
||||
int startingCredits = Math.max(0, Emulator.getConfig().getInt("new_user_credits", 0));
|
||||
int startingDuckets = Math.max(0, Emulator.getConfig().getInt("new_user_duckets", 0));
|
||||
int startingDiamonds = Math.max(0, Emulator.getConfig().getInt("new_user_diamonds", 0));
|
||||
|
||||
int newUserId = 0;
|
||||
try (PreparedStatement ins = conn.prepareStatement(
|
||||
"INSERT INTO users (username, password, mail, account_created, " +
|
||||
"ip_register, ip_current, last_online, last_login, motto, look, gender, " +
|
||||
"credits, `rank`, home_room, machine_id, auth_ticket, online) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, '', '', '0')",
|
||||
Statement.RETURN_GENERATED_KEYS)) {
|
||||
ins.setString(1, username);
|
||||
ins.setString(2, hashed);
|
||||
ins.setString(3, email);
|
||||
ins.setInt(4, now);
|
||||
ins.setString(5, ip == null ? "" : ip);
|
||||
ins.setString(6, ip == null ? "" : ip);
|
||||
ins.setInt(7, now);
|
||||
ins.setInt(8, now);
|
||||
ins.setString(9, defaultMotto);
|
||||
ins.setString(10, finalLook);
|
||||
ins.setString(11, finalGender);
|
||||
ins.setInt(12, startingCredits);
|
||||
ins.executeUpdate();
|
||||
try (ResultSet keys = ins.getGeneratedKeys()) {
|
||||
if (keys.next()) newUserId = keys.getInt(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (newUserId > 0 && (startingDuckets > 0 || startingDiamonds > 0)) {
|
||||
RegistrationSupport.seedUserCurrencies(conn, newUserId, startingDuckets, startingDiamonds);
|
||||
}
|
||||
|
||||
LOGGER.info("[auth/register] user created id={} username='{}' templateId={} credits={} duckets={} diamonds={}",
|
||||
newUserId, username, templateId, startingCredits, startingDuckets, startingDiamonds);
|
||||
|
||||
if (newUserId > 0 && templateId > 0) {
|
||||
RegistrationSupport.cloneTemplateForUser(conn, templateId, newUserId, username);
|
||||
} else if (templateId > 0) {
|
||||
LOGGER.warn("[auth/register] skipping template clone: user insert did not return an id (username='{}')", username);
|
||||
}
|
||||
|
||||
AvailabilityCache.invalidateEmail(email);
|
||||
AvailabilityCache.invalidateUsername(username);
|
||||
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("message", "Welcome aboard, " + username + "! Your account is ready — log in below with the password you just chose.");
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Register query failed for username=" + username, e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
}
|
||||
}
|
||||
|
||||
static void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
String email = readString(body, "email").trim();
|
||||
|
||||
if (!EMAIL_RE.matcher(email).matches()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Invalid email address."));
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("message", "Email sent! If an account matches that address you'll find a reset link in your inbox shortly (check spam if it doesn't show up within a minute).");
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT id, username FROM users WHERE mail = ? LIMIT 1")) {
|
||||
stmt.setString(1, email);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
int userId = rs.getInt("id");
|
||||
String username = rs.getString("username");
|
||||
String token = mintResetToken();
|
||||
long expiresAt = Instant.now().getEpochSecond() + 60L * 60L; // 1h
|
||||
|
||||
try (PreparedStatement ins = conn.prepareStatement(
|
||||
"INSERT INTO password_resets (user_id, token, expires_at, created_ip) " +
|
||||
"VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE " +
|
||||
"token = VALUES(token), expires_at = VALUES(expires_at), created_ip = VALUES(created_ip)")) {
|
||||
ins.setInt(1, userId);
|
||||
ins.setString(2, token);
|
||||
ins.setTimestamp(3, Timestamp.from(Instant.ofEpochSecond(expiresAt)));
|
||||
ins.setString(4, ip == null ? "" : ip);
|
||||
ins.executeUpdate();
|
||||
}
|
||||
|
||||
String resetUrlBase = Emulator.getConfig().getValue("password.reset.url",
|
||||
"http://localhost/reset-password");
|
||||
String fullUrl = resetUrlBase + (resetUrlBase.contains("?") ? "&" : "?") + "token=" + token;
|
||||
String subject = "Reset your Habbo password";
|
||||
String message = "Hi " + username + ",\n\n" +
|
||||
"Someone (hopefully you) requested a password reset for your Habbo account.\n" +
|
||||
"Click the link below within the next hour to choose a new password:\n\n" +
|
||||
fullUrl + "\n\n" +
|
||||
"If you didn't request this you can safely ignore this email.";
|
||||
|
||||
Emulator.getThreading().getService().submit((Runnable) () -> SmtpMailService.send(email, subject, message));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Forgot-password query failed for email=" + email, e);
|
||||
}
|
||||
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.http.DefaultFullHttpResponse;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.FullHttpResponse;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpHeaderValues;
|
||||
import io.netty.handler.codec.http.HttpResponseStatus;
|
||||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.applyCors;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.isKeepAlive;
|
||||
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson;
|
||||
|
||||
final class StaticContentEndpoints {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(StaticContentEndpoints.class);
|
||||
|
||||
private static final long NEWS_CACHE_TTL_MS = 30_000L;
|
||||
private static final int NEWS_IMAGE_MAX_BYTES = 512 * 1024;
|
||||
private static volatile NewsCacheEntry NEWS_CACHE = null;
|
||||
|
||||
private static final class NewsCacheEntry {
|
||||
final byte[] jsonBytes;
|
||||
final long expiresAt;
|
||||
|
||||
NewsCacheEntry(byte[] j, long e) {
|
||||
jsonBytes = j;
|
||||
expiresAt = e;
|
||||
}
|
||||
}
|
||||
|
||||
private StaticContentEndpoints() {
|
||||
}
|
||||
|
||||
static void handleRoomTemplates(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||
JsonArray templates = new JsonArray();
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT template_id, title, description, thumbnail " +
|
||||
"FROM room_templates WHERE enabled = '1' " +
|
||||
"ORDER BY sort_order ASC, template_id ASC")) {
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
JsonObject t = new JsonObject();
|
||||
t.addProperty("templateId", rs.getInt("template_id"));
|
||||
t.addProperty("title", rs.getString("title"));
|
||||
t.addProperty("description", rs.getString("description"));
|
||||
t.addProperty("thumbnail", rs.getString("thumbnail"));
|
||||
templates.add(t);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("room-templates list failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
return;
|
||||
}
|
||||
JsonObject res = new JsonObject();
|
||||
res.add("templates", templates);
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, res);
|
||||
}
|
||||
|
||||
static void handleNews(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||
long now = System.currentTimeMillis();
|
||||
NewsCacheEntry cached = NEWS_CACHE;
|
||||
|
||||
if (cached == null || cached.expiresAt < now) {
|
||||
JsonArray items = new JsonArray();
|
||||
int limit = Math.max(1, Math.min(20, Emulator.getConfig().getInt("login.news.limit", 5)));
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT id, title, body, image, link_text, link_url " +
|
||||
"FROM ui_news WHERE enabled = 1 " +
|
||||
"ORDER BY sort_order ASC, id DESC LIMIT ?")) {
|
||||
stmt.setInt(1, limit);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
int id = rs.getInt("id");
|
||||
JsonObject n = new JsonObject();
|
||||
n.addProperty("id", id);
|
||||
n.addProperty("title", rs.getString("title"));
|
||||
n.addProperty("body", rs.getString("body"));
|
||||
|
||||
String image = rs.getString("image");
|
||||
if (image != null && image.length() > NEWS_IMAGE_MAX_BYTES) {
|
||||
LOGGER.warn("ui_news id={} image is {} bytes (>{}KB cap), omitting in response",
|
||||
id, image.length(), NEWS_IMAGE_MAX_BYTES / 1024);
|
||||
image = null;
|
||||
}
|
||||
n.addProperty("image", image);
|
||||
|
||||
n.addProperty("linkText", rs.getString("link_text"));
|
||||
n.addProperty("linkUrl", rs.getString("link_url"));
|
||||
items.add(n);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("ui_news list failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject res = new JsonObject();
|
||||
res.add("news", items);
|
||||
byte[] bytes = res.toString().getBytes(StandardCharsets.UTF_8);
|
||||
cached = new NewsCacheEntry(bytes, now + NEWS_CACHE_TTL_MS);
|
||||
NEWS_CACHE = cached;
|
||||
}
|
||||
|
||||
FullHttpResponse response = new DefaultFullHttpResponse(
|
||||
HttpVersion.HTTP_1_1, HttpResponseStatus.OK,
|
||||
Unpooled.wrappedBuffer(cached.jsonBytes));
|
||||
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
|
||||
response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, cached.jsonBytes.length);
|
||||
response.headers().set(HttpHeaderNames.CACHE_CONTROL, "public, max-age=30");
|
||||
applyCors(req, response);
|
||||
boolean keepAlive = isKeepAlive(req);
|
||||
if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
|
||||
var future = ctx.writeAndFlush(response);
|
||||
if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE);
|
||||
}
|
||||
|
||||
static void handleServerKey(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||
try {
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("publicKey", com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.publicKeyBase64());
|
||||
ok.addProperty("algorithm", "ECDSA-P256-SHA256");
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("server-key fetch failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user