/* * Copyright © 2004-2024 L2J Server * * This file is part of L2J Server. * * L2J Server is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * L2J Server is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.l2jserver.loginserver; import static com.l2jserver.loginserver.config.Configuration.server; import static java.nio.charset.StandardCharsets.UTF_8; import static java.security.spec.RSAKeyGenParameterSpec.F4; import java.net.InetAddress; import java.net.UnknownHostException; import java.security.KeyPairGenerator; import java.security.MessageDigest; import java.security.interfaces.RSAPrivateKey; import java.security.spec.RSAKeyGenParameterSpec; import java.util.ArrayList; import java.util.Base64; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import javax.crypto.Cipher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.l2jserver.commons.database.ConnectionFactory; import com.l2jserver.commons.util.Rnd; import com.l2jserver.loginserver.GameServerTable.GameServerInfo; import com.l2jserver.loginserver.model.AccountInfo; import com.l2jserver.loginserver.network.L2LoginClient; import com.l2jserver.loginserver.network.gameserverpackets.ServerStatus; import com.l2jserver.loginserver.network.serverpackets.LoginFail.LoginFailReason; import com.l2jserver.loginserver.security.ScrambledKeyPair; /** * Login Controller. * @author Zoey76 * @version 2.6.1.0 */ public class LoginController { private static final Logger LOG = LoggerFactory.getLogger(LoginController.class); /** Time before kicking the client if he didn't log yet */ public static final int LOGIN_TIMEOUT = 60 * 1000; /** Authed Clients on LoginServer */ protected final Map<String, L2LoginClient> _loginServerClients = new ConcurrentHashMap<>(); private final Map<InetAddress, Integer> _failedLoginAttempts = new ConcurrentHashMap<>(); private final Map<InetAddress, Long> _bannedIps = new ConcurrentHashMap<>(); protected final ScrambledKeyPair[] _keyPairs; protected byte[][] _blowfishKeys; private static final int BLOWFISH_KEYS = 20; // SQL Queries private static final String USER_INFO_SELECT = "SELECT login, password, IF(? > value OR value IS NULL, accessLevel, -1) AS accessLevel, lastServer FROM accounts LEFT JOIN (account_data) ON (account_data.account_name=accounts.login AND account_data.var=\"ban_temp\") WHERE login=?"; private static final String AUTO_CREATE_ACCOUNTS_INSERT = "INSERT INTO accounts (login, password, lastactive, accessLevel, lastIP) values (?, ?, ?, ?, ?)"; private static final String ACCOUNT_INFO_UPDATE = "UPDATE accounts SET lastactive = ?, lastIP = ? WHERE login = ?"; private static final String ACCOUNT_LAST_SERVER_UPDATE = "UPDATE accounts SET lastServer = ? WHERE login = ?"; private static final String ACCOUNT_ACCESS_LEVEL_UPDATE = "UPDATE accounts SET accessLevel = ? WHERE login = ?"; private static final String ACCOUNT_IPS_UPDATE = "UPDATE accounts SET pcIp = ?, hop1 = ?, hop2 = ?, hop3 = ?, hop4 = ? WHERE login = ?"; private static final String ACCOUNT_IPAUTH_SELECT = "SELECT * FROM accounts_ipauth WHERE login = ?"; private LoginController() { LOG.info("Loading Login Controller..."); _keyPairs = new ScrambledKeyPair[10]; try { final var keygen = KeyPairGenerator.getInstance("RSA"); final var spec = new RSAKeyGenParameterSpec(1024, F4); keygen.initialize(spec); for (int i = 0; i < 10; i++) { _keyPairs[i] = new ScrambledKeyPair(keygen.generateKeyPair()); } testCipher((RSAPrivateKey) _keyPairs[0].getPair().getPrivate()); LOG.info("Cached 10 KeyPairs for RSA communication."); } catch (Exception ex) { LOG.error("There has been an error loading the key pairs!", ex); } // Store keys for blowfish communication generateBlowFishKeys(); final var purge = new PurgeThread(); purge.setDaemon(true); purge.start(); } /** * This is mostly to force the initialization of the Crypto Implementation, avoiding it being done on runtime when its first needed.<BR> * In short, it avoids the worst-case execution time on runtime by doing it on loading. * @param key Any private RSA Key just for testing purposes. */ private void testCipher(RSAPrivateKey key) throws Exception { // avoid worst-case execution, KenM Cipher rsaCipher = Cipher.getInstance("RSA/ECB/nopadding"); rsaCipher.init(Cipher.DECRYPT_MODE, key); } private void generateBlowFishKeys() { _blowfishKeys = new byte[BLOWFISH_KEYS][16]; for (int i = 0; i < BLOWFISH_KEYS; i++) { for (int j = 0; j < _blowfishKeys[i].length; j++) { _blowfishKeys[i][j] = (byte) (Rnd.nextInt(255) + 1); } } LOG.info("Stored {} keys for Blowfish communication.", _blowfishKeys.length); } public byte[] getBlowfishKey() { return _blowfishKeys[(int) (Math.random() * BLOWFISH_KEYS)]; } public SessionKey assignSessionKeyToClient(String account, L2LoginClient client) { SessionKey key; key = new SessionKey(Rnd.nextInt(), Rnd.nextInt(), Rnd.nextInt(), Rnd.nextInt()); _loginServerClients.put(account, client); return key; } public void removeAuthedLoginClient(String account) { if (account == null) { return; } _loginServerClients.remove(account); } public L2LoginClient getAuthedClient(String account) { return _loginServerClients.get(account); } public AccountInfo retrieveAccountInfo(InetAddress clientAddr, String login, String password) { return retrieveAccountInfo(clientAddr, login, password, true); } private void recordFailedLoginAttempt(InetAddress addr) { final var failedLoginAttempts = _failedLoginAttempts.getOrDefault(addr, 0) + 1; _failedLoginAttempts.put(addr, failedLoginAttempts); if (failedLoginAttempts >= server().getLoginTryBeforeBan()) { addBanForAddress(addr, server().getLoginBlockAfterBan() * 1000); // we need to clear the failed login attempts here, so after the ip ban is over the client has another 5 attempts clearFailedLoginAttempts(addr); LOG.warn("Added banned address {}, too many login attempts!", addr.getHostAddress()); } } private void clearFailedLoginAttempts(InetAddress addr) { _failedLoginAttempts.remove(addr); } private AccountInfo retrieveAccountInfo(InetAddress addr, String login, String password, boolean autoCreateIfEnabled) { try { final var md = MessageDigest.getInstance("SHA"); final var raw = password.getBytes(UTF_8); final var hashBase64 = Base64.getEncoder().encodeToString(md.digest(raw)); try (var con = ConnectionFactory.getInstance().getConnection(); var ps = con.prepareStatement(USER_INFO_SELECT)) { ps.setString(1, Long.toString(System.currentTimeMillis())); ps.setString(2, login); try (var rs = ps.executeQuery()) { if (rs.next()) { if (server().isDebug()) { LOG.info("Account {} exists.", login); } final var info = new AccountInfo(rs.getString("login"), rs.getString("password"), rs.getInt("accessLevel"), rs.getInt("lastServer")); if (!info.checkPassHash(hashBase64)) { recordFailedLoginAttempt(addr); return null; } clearFailedLoginAttempts(addr); return info; } } } if (!autoCreateIfEnabled || !server().autoCreateAccounts()) { // account does not exist and auto create account is not desired recordFailedLoginAttempt(addr); return null; } try (var con = ConnectionFactory.getInstance().getConnection(); var ps = con.prepareStatement(AUTO_CREATE_ACCOUNTS_INSERT)) { ps.setString(1, login); ps.setString(2, hashBase64); ps.setLong(3, System.currentTimeMillis()); ps.setInt(4, server().autoCreateAccountsAccessLevel()); ps.setString(5, addr.getHostAddress()); ps.execute(); } catch (Exception ex) { LOG.warn("There has been an error auto-creating the account {}!", login, ex); return null; } LOG.info("Auto-created account {}.", login); return retrieveAccountInfo(addr, login, password, false); } catch (Exception ex) { LOG.warn("There has been an error getting account info for {}!", login, ex); return null; } } public AuthLoginResult tryCheckinAccount(L2LoginClient client, InetAddress address, AccountInfo info) { if (info.getAccessLevel() < 0) { if (info.getAccessLevel() == server().autoCreateAccountsAccessLevel()) { return AuthLoginResult.ACCOUNT_INACTIVE; } return AuthLoginResult.ACCOUNT_BANNED; } AuthLoginResult ret = AuthLoginResult.INVALID_PASSWORD; // check auth if (canCheckIn(client, address, info)) { // login was successful, verify presence on game servers ret = AuthLoginResult.ALREADY_ON_GS; if (!isAccountInAnyGameServer(info.getLogin())) { // account isn't on any GS verify LS itself ret = AuthLoginResult.ALREADY_ON_LS; if (_loginServerClients.putIfAbsent(info.getLogin(), client) == null) { ret = AuthLoginResult.AUTH_SUCCESS; } } } return ret; } /** * Adds the address to the ban list of the login server, with the given end time in milliseconds. * @param address The Address to be banned. * @param expiration Timestamp in milliseconds when this ban expires * @throws UnknownHostException if the address is invalid. */ public void addBanForAddress(String address, long expiration) throws Exception { _bannedIps.putIfAbsent(InetAddress.getByName(address), expiration); } /** * Adds the address to the ban list of the login server, with the given duration. * @param address The Address to be banned. * @param duration is milliseconds */ public void addBanForAddress(InetAddress address, long duration) { _bannedIps.putIfAbsent(address, System.currentTimeMillis() + duration); } public boolean isBannedAddress(InetAddress address) throws Exception { String[] parts = address.getHostAddress().split("\\."); Long bi = _bannedIps.get(address); if (bi == null) { bi = _bannedIps.get(InetAddress.getByName(parts[0] + "." + parts[1] + "." + parts[2] + ".0")); } if (bi == null) { bi = _bannedIps.get(InetAddress.getByName(parts[0] + "." + parts[1] + ".0.0")); } if (bi == null) { bi = _bannedIps.get(InetAddress.getByName(parts[0] + ".0.0.0")); } if (bi != null) { if ((bi > 0) && (bi < System.currentTimeMillis())) { _bannedIps.remove(address); LOG.info("Removed expired IP address ban {}.", address.getHostAddress()); return false; } return true; } return false; } public Map<InetAddress, Long> getBannedIps() { return _bannedIps; } /** * Remove the specified address from the ban list * @param address The address to be removed from the ban list * @return true if the ban was removed, false if there was no ban for this ip */ public boolean removeBanForAddress(InetAddress address) { return _bannedIps.remove(address) != null; } /** * Remove the specified address from the ban list * @param address The address to be removed from the ban list * @return true if the ban was removed, false if there was no ban for this ip or the address was invalid. */ public boolean removeBanForAddress(String address) { try { return this.removeBanForAddress(InetAddress.getByName(address)); } catch (Exception e) { return false; } } public SessionKey getKeyForAccount(String account) { L2LoginClient client = _loginServerClients.get(account); if (client != null) { return client.getSessionKey(); } return null; } public boolean isAccountInAnyGameServer(String account) { Collection<GameServerInfo> serverList = GameServerTable.getInstance().getRegisteredGameServers().values(); for (GameServerInfo gsi : serverList) { GameServerThread gst = gsi.getGameServerThread(); if ((gst != null) && gst.hasAccountOnGameServer(account)) { return true; } } return false; } public GameServerInfo getAccountOnGameServer(String account) { Collection<GameServerInfo> serverList = GameServerTable.getInstance().getRegisteredGameServers().values(); for (GameServerInfo gsi : serverList) { GameServerThread gst = gsi.getGameServerThread(); if ((gst != null) && gst.hasAccountOnGameServer(account)) { return gsi; } } return null; } public void getCharactersOnAccount(String account) { Collection<GameServerInfo> serverList = GameServerTable.getInstance().getRegisteredGameServers().values(); for (GameServerInfo gsi : serverList) { if (gsi.isAuthed()) { gsi.getGameServerThread().requestCharacters(account); } } } public boolean isLoginPossible(L2LoginClient client, int serverId) { GameServerInfo gsi = GameServerTable.getInstance().getRegisteredGameServerById(serverId); int access = client.getAccessLevel(); if ((gsi != null) && gsi.isAuthed()) { boolean loginOk = ((gsi.getCurrentPlayerCount() < gsi.getMaxPlayers()) && (gsi.getStatus() != ServerStatus.STATUS_GM_ONLY)) || (access > 0); if (loginOk && (client.getLastServer() != serverId)) { try (var con = ConnectionFactory.getInstance().getConnection(); var ps = con.prepareStatement(ACCOUNT_LAST_SERVER_UPDATE)) { ps.setInt(1, serverId); ps.setString(2, client.getAccount()); ps.executeUpdate(); } catch (Exception ex) { LOG.warn("There has been an error setting last server for account {}!", client.getAccount(), ex); } } return loginOk; } return false; } public void setAccountAccessLevel(String account, int banLevel) { try (var con = ConnectionFactory.getInstance().getConnection(); var ps = con.prepareStatement(ACCOUNT_ACCESS_LEVEL_UPDATE)) { ps.setInt(1, banLevel); ps.setString(2, account); ps.executeUpdate(); } catch (Exception ex) { LOG.warn("There has been an error setting account level for account {}!", account, ex); } } public void setAccountLastTracert(String account, String pcIp, String hop1, String hop2, String hop3, String hop4) { try (var con = ConnectionFactory.getInstance().getConnection(); var ps = con.prepareStatement(ACCOUNT_IPS_UPDATE)) { ps.setString(1, pcIp); ps.setString(2, hop1); ps.setString(3, hop2); ps.setString(4, hop3); ps.setString(5, hop4); ps.setString(6, account); ps.executeUpdate(); } catch (Exception ex) { LOG.warn("There has been an error setting last tracert for account {}!", account, ex); } } public void setCharactersOnServer(String account, int charsNum, long[] timeToDel, int serverId) { final var client = _loginServerClients.get(account); if (client == null) { return; } if (charsNum > 0) { client.setCharsOnServ(serverId, charsNum); } if (timeToDel.length > 0) { client.serCharsWaitingDelOnServ(serverId, timeToDel); } } /** * This method returns one of the cached {@link ScrambledKeyPair ScrambledKeyPairs} for communication with Login Clients. * @return a scrambled keypair */ public ScrambledKeyPair getScrambledRSAKeyPair() { return _keyPairs[Rnd.nextInt(10)]; } /** * @param client the client * @param address client host address * @param info the account info to check in * @return true when ok to check in, false otherwise */ public boolean canCheckIn(L2LoginClient client, InetAddress address, AccountInfo info) { try { List<InetAddress> ipWhiteList = new ArrayList<>(); List<InetAddress> ipBlackList = new ArrayList<>(); try (var con = ConnectionFactory.getInstance().getConnection(); var ps = con.prepareStatement(ACCOUNT_IPAUTH_SELECT)) { ps.setString(1, info.getLogin()); try (var rs = ps.executeQuery()) { while (rs.next()) { final var ip = rs.getString("ip"); if (!isValidIPAddress(ip)) { continue; } final var type = rs.getString("type"); if (type.equals("allow")) { ipWhiteList.add(InetAddress.getByName(ip)); } else if (type.equals("deny")) { ipBlackList.add(InetAddress.getByName(ip)); } } } } // Check IP if (!ipWhiteList.isEmpty() || !ipBlackList.isEmpty()) { if (!ipWhiteList.isEmpty() && !ipWhiteList.contains(address)) { LOG.warn("Account checkin attempt from address {} not present on whitelist for account {}!", address.getHostAddress(), info.getLogin()); return false; } if (!ipBlackList.isEmpty() && ipBlackList.contains(address)) { LOG.warn("Account checkin attempt from address {} on blacklist for account {}!", address.getHostAddress(), info.getLogin()); return false; } } client.setAccessLevel(info.getAccessLevel()); client.setLastServer(info.getLastServer()); try (var con = ConnectionFactory.getInstance().getConnection(); var ps = con.prepareStatement(ACCOUNT_INFO_UPDATE)) { ps.setLong(1, System.currentTimeMillis()); ps.setString(2, address.getHostAddress()); ps.setString(3, info.getLogin()); ps.execute(); } return true; } catch (Exception ex) { LOG.warn("There has been an error logging in!", ex); return false; } } public boolean isValidIPAddress(String ipAddress) { String[] parts = ipAddress.split("\\."); if (parts.length != 4) { return false; } for (String s : parts) { int i = Integer.parseInt(s); if ((i < 0) || (i > 255)) { return false; } } return true; } class PurgeThread extends Thread { public PurgeThread() { setName("PurgeThread"); } @Override public void run() { while (!isInterrupted()) { for (L2LoginClient client : _loginServerClients.values()) { if (client == null) { continue; } if ((client.getConnectionStartTime() + LOGIN_TIMEOUT) < System.currentTimeMillis()) { client.close(LoginFailReason.REASON_ACCESS_FAILED); } } try { Thread.sleep(LOGIN_TIMEOUT / 2); } catch (InterruptedException e) { return; } } } } public enum AuthLoginResult { INVALID_PASSWORD, ACCOUNT_INACTIVE, ACCOUNT_BANNED, ALREADY_ON_LS, ALREADY_ON_GS, AUTH_SUCCESS } public static LoginController getInstance() { return SingletonHolder.INSTANCE; } private static class SingletonHolder { protected static final LoginController INSTANCE = new LoginController(); } }