defmodule Odinsea.Login.Handler do @moduledoc """ Login server packet handlers. Ported from Java CharLoginHandler.java Handles all login-related operations: - Authentication (login/password) - World/server selection - Character list - Character creation/deletion - Character selection (migration to channel) """ require Logger alias Odinsea.Net.Packet.{In, Out} alias Odinsea.Net.Cipher.{ClientCrypto, LoginCrypto} alias Odinsea.Net.PacketLogger alias Odinsea.Login.Packets alias Odinsea.Constants.Server alias Odinsea.Database.Context alias Odinsea.Game.{JobType, InventoryType} # ================================================================================================== # Permission Request (Client Hello / Version Check) # ================================================================================================== @doc """ Handles permission request (initial client hello). Validates client version, locale, and patch. Packet structure: - byte: locale - short: major version - short: minor version (patch) """ def on_permission_request(packet, state) do {locale, packet} = In.decode_byte(packet) {major, packet} = In.decode_short(packet) {minor, _packet} = In.decode_short(packet) # Validate version if locale != Server.maple_locale() or major != Server.maple_version() or Integer.to_string(minor) != Server.maple_patch() do Logger.warning("Invalid client version: locale=#{locale}, major=#{major}, minor=#{minor}") {:disconnect, :invalid_version} else Logger.debug("Permission request validated") {:ok, state} end end # ================================================================================================== # Password Check (Authentication) # ================================================================================================== @doc """ Handles password check (login authentication). Packet structure: - string: username - string: password (RSA encrypted if enabled) """ def on_check_password(packet, state) do {username, packet} = In.decode_string(packet) {password_encrypted, _packet} = In.decode_string(packet) # Decrypt password if encryption is enabled password = if Application.get_env(:odinsea, :encrypt_passwords, false) do case LoginCrypto.decrypt_rsa(password_encrypted) do {:ok, decrypted} -> decrypted {:error, _} -> password_encrypted end else password_encrypted end Logger.info("Login attempt: username=#{username} from #{state.ip}") # Check if IP/MAC is banned features = Application.get_env(:odinsea, :features, []) skip_maccheck = Keyword.get(features, :skip_maccheck, false) ip_banned = Context.ip_banned?(state.ip) mac_banned = if skip_maccheck or state.macs == [] do false else Enum.any?(state.macs, &Context.mac_banned?/1) end if (ip_banned || mac_banned) do Logger.warning("Banned IP/MAC attempted login: ip=#{state.ip}, macs=#{inspect(state.macs)}") # If MAC banned, also ban the IP for enforcement if mac_banned do Context.ban_ip_address(state.ip, "Enforcing account ban, account #{username}", false, 4) end response = Packets.get_login_failed(3) state = send_packet(state, response) {:ok, state} else # Authenticate with database case Context.authenticate_user(username, password, state.ip) do {:ok, account_info} -> # Check if account is banned (perm or temp) temp_ban_info = Context.get_temp_ban_info(account_info.account_id) if temp_ban_info do Logger.warning("Temp banned account attempted login: #{username}") response = Packets.get_temp_ban( format_timestamp(temp_ban_info.expires), temp_ban_info.reason || "" ) state = send_packet(state, response) {:ok, state} else # Check if already logged in - kick other session login_state = Context.get_login_state(account_info.account_id) if login_state > 0 do Logger.warning("Account already logged in, kicking other session: #{username}") # Kick the existing session kick_existing_session(account_info.account_id, username) # Small delay to allow kick to process Process.sleep(100) end # Update login state to logged in Context.update_login_state(account_info.account_id, 2, state.ip) # Send success response response = Packets.get_auth_success( account_info.account_id, account_info.username, account_info.gender, account_info.is_gm, account_info.second_password ) state = send_packet(state, response) new_state = state |> Map.put(:logged_in, true) |> Map.put(:account_id, account_info.account_id) |> Map.put(:account_name, account_info.username) |> Map.put(:gender, account_info.gender) |> Map.put(:is_gm, account_info.is_gm) |> Map.put(:second_password, account_info.second_password) |> Map.put(:login_attempts, 0) # Send world info immediately after auth success (Java: LoginWorker.registerClient) on_world_info_request(new_state) end {:error, :invalid_credentials} -> # Increment login attempts login_attempts = Map.get(state, :login_attempts, 0) + 1 if login_attempts > 5 do Logger.warning("Too many login attempts from #{state.ip}") {:disconnect, :too_many_attempts} else # Send login failed (reason 4 = incorrect password) response = Packets.get_login_failed(4) state = send_packet(state, response) new_state = Map.put(state, :login_attempts, login_attempts) {:ok, new_state} end {:error, :account_not_found} -> # Send login failed (reason 5 = not registered ID) response = Packets.get_login_failed(5) state = send_packet(state, response) {:ok, state} {:error, :already_logged_in} -> # Try to kick the existing session and allow retry Logger.warning("Already logged in, attempting to kick session for: #{username}") kick_existing_session_by_name(username) Process.sleep(100) # Send login failed (reason 7 = already logged in) but client can retry response = Packets.get_login_failed(7) state = send_packet(state, response) {:ok, state} {:error, :banned} -> response = Packets.get_perm_ban(0) state = send_packet(state, response) {:ok, state} end end end # ================================================================================================== # World Info Request (Server List) # ================================================================================================== @doc """ Handles world info request. Sends the list of available worlds/servers and channels. """ def on_world_info_request(state) do # TODO: Get actual channel load from World server # For now, send a stub response channel_load = get_channel_load() # Send server list server_list = Packets.get_server_list( 0, # server_id "Odinsea", # world_name 0, # flag (0=normal, 1=event, 2=new, 3=hot) "Welcome to Odinsea!", # event_message channel_load ) state = send_packet(state, server_list) # Send end of server list end_list = Packets.get_end_of_server_list() state = send_packet(state, end_list) # Send latest connected world latest_world = Packets.get_latest_connected_world(0) state = send_packet(state, latest_world) # Send recommended world message recommend = Packets.get_recommend_world_message(0, "Join now!") state = send_packet(state, recommend) {:ok, state} end # ================================================================================================== # Check User Limit (Server Capacity) # ================================================================================================== @doc """ Handles check user limit request. Returns server population status. """ def on_check_user_limit(state) do # TODO: Get actual user count from World server {users_online, user_limit} = get_user_count() status = cond do users_online >= user_limit -> 2 # Full users_online * 2 >= user_limit -> 1 # Highly populated true -> 0 # Normal end response = Packets.get_server_status(status) state = send_packet(state, response) {:ok, state} end # ================================================================================================== # Select World (Load Character List) # ================================================================================================== @doc """ Handles world selection. Validates world/channel and sends character list. Packet structure: - byte: world_id - byte: channel_id (0-based, add 1 for actual channel) """ def on_select_world(packet, state) do if not Map.get(state, :logged_in, false) do Logger.warning("Select world: not logged in") {:disconnect, :not_logged_in} else {world_id, packet} = In.decode_byte(packet) {channel_id, _packet} = In.decode_byte(packet) actual_channel = channel_id + 1 # Validate world if world_id != 0 do Logger.warning("Invalid world ID: #{world_id}") response = Packets.get_login_failed(10) state = send_packet(state, response) {:ok, state} else # TODO: Check if channel is available # if not World.is_channel_available(actual_channel) do # response = Packets.get_login_failed(10) # send_packet(state, response) # {:ok, state} # else # Load character list from database characters = load_characters(state.account_id, world_id) # Store character IDs in state for later validation char_ids = Enum.map(characters, & &1.id) response = Packets.get_char_list( characters, state.second_password, 3 # character slots ) state = send_packet(state, response) new_state = state |> Map.put(:world, world_id) |> Map.put(:channel, actual_channel) |> Map.put(:character_ids, char_ids) {:ok, new_state} end end end # ================================================================================================== # Check Duplicated ID (Character Name Availability) # ================================================================================================== @doc """ Handles character name availability check. Packet structure: - string: character_name """ def on_check_duplicated_id(packet, state) do if not Map.get(state, :logged_in, false) do {:disconnect, :not_logged_in} else {char_name, _packet} = In.decode_string(packet) # Check if name is forbidden or already exists name_used = check_name_used(char_name, state) response = Packets.get_char_name_response(char_name, name_used) state = send_packet(state, response) {:ok, state} end end # ================================================================================================== # Create New Character # ================================================================================================== @doc """ Handles character creation. Packet structure: - string: name - int: job_type (0=Resistance, 1=Adventurer, 2=Cygnus, 3=Aran, 4=Evan) - short: dual_blade (1=DB, 0=Adventurer) - byte: gender (GMS only) - int: face - int: hair - int: hair_color - int: skin_color - int: top - int: bottom - int: shoes - int: weapon """ def on_create_new_character(packet, state) do if not Map.get(state, :logged_in, false) do {:disconnect, :not_logged_in} else {name, packet} = In.decode_string(packet) {job_type_int, packet} = In.decode_int(packet) {dual_blade, packet} = In.decode_short(packet) {gender, packet} = In.decode_byte(packet) {face, packet} = In.decode_int(packet) {hair, packet} = In.decode_int(packet) {hair_color, packet} = In.decode_int(packet) {skin_color, packet} = In.decode_int(packet) {top, packet} = In.decode_int(packet) {bottom, packet} = In.decode_int(packet) {shoes, packet} = In.decode_int(packet) {weapon, _packet} = In.decode_int(packet) Logger.info("Create character: name=#{name}, job_type=#{job_type_int}") # Validate name is not forbidden and doesn't exist if check_name_used(name, state) do response = Packets.get_add_new_char_entry(nil, false) state = send_packet(state, response) {:ok, state} else # TODO: Validate appearance items are eligible for gender/job type # For now, accept the items as provided # Create character with default stats job_type = JobType.from_int(job_type_int) default_stats = Context.get_default_stats_for_job(job_type_int, dual_blade) default_map = Context.get_default_map_for_job(job_type_int) # Combine hair with hair color final_hair = hair + hair_color # Build character attributes attrs = Map.merge(default_stats, %{ name: name, accountid: state.account_id, world: state.world, face: face, hair: final_hair, gender: gender, skin: skin_color, map: default_map }) case Context.create_character(attrs) do {:ok, character} -> # Add default items to character inventory :ok = add_default_items(character.id, top, bottom, shoes, weapon, job_type_int) # Add job-specific starter items and quests :ok = add_job_specific_starters(character.id, job_type_int) Logger.info("Character created successfully: id=#{character.id}, name=#{name}") # Reload character with full data char_data = Context.load_character(character.id) response = Packets.get_add_new_char_entry(char_data, true) state = send_packet(state, response) # Add character ID to state's character list new_char_ids = [character.id | Map.get(state, :character_ids, [])] new_state = Map.put(state, :character_ids, new_char_ids) {:ok, new_state} {:error, changeset} -> Logger.error("Failed to create character: #{inspect(changeset.errors)}") response = Packets.get_add_new_char_entry(nil, false) state = send_packet(state, response) {:ok, state} end end end end # ================================================================================================== # Create Ultimate (Cygnus Knight → Ultimate Adventurer) # ================================================================================================== @doc """ Handles ultimate adventurer creation (Cygnus Knight transformation). """ def on_create_ultimate(_packet, state) do # TODO: Implement ultimate creation # Requires level 120 Cygnus Knight in Ereve Logger.debug("Create ultimate request (not implemented)") {:ok, state} end # ================================================================================================== # Delete Character # ================================================================================================== @doc """ Handles character deletion. Packet structure: - byte: has_spw (1 if second password provided) - string: second_password (if enabled) - string: asia_password (legacy, usually empty) - int: character_id """ def on_delete_character(packet, state) do if not Map.get(state, :logged_in, false) do {:disconnect, :not_logged_in} else {has_spw, packet} = In.decode_byte(packet) # Read second password if enabled {spw, packet} = if has_spw > 0 do In.decode_string(packet) else {"", packet} end {_asia_pw, packet} = In.decode_string(packet) {character_id, _packet} = In.decode_int(packet) Logger.info("Delete character: character_id=#{character_id}, account=#{state.account_name}") # Validate second password if account has one spw_valid = validate_second_password(state, spw) result = cond do not spw_valid -> Logger.warning("Delete character: invalid second password") 12 # Wrong Password not character_belongs_to_account?(character_id, state) -> Logger.warning("Delete character: character does not belong to account") 1 # General error true -> # Attempt to delete character case Context.delete_character(character_id) do :ok -> Logger.info("Character deleted successfully: id=#{character_id}") # Remove from state's character list 0 # Success {:error, reason} -> Logger.error("Failed to delete character: #{inspect(reason)}") 1 # General error end end response = Packets.get_delete_char_response(character_id, result) state = send_packet(state, response) # Update state if successful new_state = if result == 0 do new_char_ids = Enum.reject(Map.get(state, :character_ids, []), &(&1 == character_id)) Map.put(state, :character_ids, new_char_ids) else state end {:ok, new_state} end end # ================================================================================================== # Select Character (Enter Game / Migrate to Channel) # ================================================================================================== @doc """ Handles character selection (enter game). Initiates migration to the selected channel. Packet structure: - byte: set_spw (1 if setting second password) - int: character_id """ def on_select_character(packet, state) do if not Map.get(state, :logged_in, false) do {:disconnect, :not_logged_in} else {set_spw, packet} = In.decode_byte(packet) {character_id, _packet} = In.decode_int(packet) Logger.info("Select character: character_id=#{character_id}, channel=#{state.channel}") # Validate character belongs to account unless character_belongs_to_account?(character_id, state) do Logger.warning("Select character: character does not belong to account") {:disconnect, :invalid_character} else # Handle setting second password if requested if set_spw > 0 do {new_spw, _} = In.decode_string(packet) # Validate second password length if String.length(new_spw) < 6 || String.length(new_spw) > 16 do response = Packets.get_second_pw_error(0x14) state = send_packet(state, response) {:ok, state} else # Update second password Context.update_second_password(state.account_id, new_spw) Logger.info("Second password set for account: #{state.account_name}") # Continue with character selection do_character_migration(character_id, state) end else do_character_migration(character_id, state) end end end end defp do_character_migration(character_id, state) do # Load character data case Context.load_character(character_id) do nil -> Logger.error("Failed to load character: id=#{character_id}") {:disconnect, :character_not_found} character -> # Register migration token with channel server migration_token = generate_migration_token() # Store migration info in Redis/ETS for channel server :ok = register_migration_token( migration_token, character_id, state.account_id, state.channel ) # Update login state to server transition Context.update_login_state(state.account_id, 1, state.ip) # Get channel IP and port {channel_ip, channel_port} = get_channel_endpoint(state.channel) Logger.info("Character migration: char=#{character.name} to channel #{state.channel} (#{channel_ip}:#{channel_port})") # Send migration command response = Packets.get_server_ip(false, channel_ip, channel_port, character_id) state = send_packet(state, response) new_state = state |> Map.put(:character_id, character_id) |> Map.put(:migration_token, migration_token) {:ok, new_state} end end # ================================================================================================== # Check Second Password # ================================================================================================== @doc """ Handles second password verification (SPW / PIC). Packet structure: - string: second_password - int: character_id """ def on_check_spw_request(packet, state) do {spw, packet} = In.decode_string(packet) {character_id, _packet} = In.decode_int(packet) # Validate character belongs to account unless character_belongs_to_account?(character_id, state) do {:disconnect, :invalid_character} else stored_spw = Map.get(state, :second_password) if stored_spw == nil or stored_spw == spw do # Success - migrate to channel do_character_migration(character_id, state) else # Failure - send error response = Packets.get_second_pw_error(15) # Incorrect SPW state = send_packet(state, response) {:ok, state} end end end # ================================================================================================== # RSA Key Request # ================================================================================================== @doc """ Handles RSA key request. Sends RSA public key and login background. """ def on_rsa_key(_packet, state) do # TODO: Check if custom client is enabled # TODO: Send damage cap packet if custom client # Send login background bg_response = Packets.get_login_background(Server.maplogin_default()) state = send_packet(state, bg_response) # Send RSA public key key_response = Packets.get_rsa_key(Server.pub_key()) state = send_packet(state, key_response) {:ok, state} end # ================================================================================================== # Helper Functions # ================================================================================================== defp send_packet(%{socket: socket, crypto: crypto} = state, packet_data) do # Flatten iodata to binary for pattern matching packet_data = IO.iodata_to_binary(packet_data) # Extract opcode from packet data (first 2 bytes) <> = packet_data # Log the packet context = %{ ip: state.ip, server_type: :login } PacketLogger.log_server_packet(opcode, rest, context) # Encrypt the data (Shanda then AES) and morph send IV {updated_crypto, encrypted, header} = ClientCrypto.encrypt(crypto, packet_data) # Send encrypted packet with 4-byte crypto header full_packet = header <> encrypted case :gen_tcp.send(socket, full_packet) do :ok -> %{state | crypto: updated_crypto} {:error, reason} -> Logger.error("Failed to send packet: #{inspect(reason)}") state end end defp send_packet(state, _packet_data) do # Socket not available in state Logger.error("Cannot send packet: socket not in state") state end defp authenticate_user(username, password, _state) do # Delegated to Context.authenticate_user/3 Context.authenticate_user(username, password) end defp get_channel_load do # TODO: Get actual channel load from World server # For now, return stub data %{ 1 => 100, 2 => 200, 3 => 150 } end defp get_user_count do # TODO: Get actual user count from World server {50, 1000} end defp load_characters(account_id, world_id) do Context.load_character_entries(account_id, world_id) end defp check_name_used(char_name, _state) do # Check if name is forbidden or already exists Context.forbidden_name?(char_name) or Context.character_name_exists?(char_name) end defp character_belongs_to_account?(character_id, state) do char_ids = Map.get(state, :character_ids, []) character_id in char_ids end defp validate_second_password(state, provided_spw) do stored_spw = Map.get(state, :second_password) # If no second password set, accept any if stored_spw == nil || stored_spw == "" do true else stored_spw == provided_spw end end defp kick_existing_session(account_id, username) do # TODO: Implement session kicking via World server or Redis pub/sub # For now, just update login state to force disconnect on next tick Context.update_login_state(account_id, 0) # Publish kick message to Redis for other servers Odinsea.Database.Redis.publish("kick_session", %{account_id: account_id, username: username}) :ok end defp kick_existing_session_by_name(username) do # Find account by name and kick case Context.get_account_by_name(username) do nil -> :ok account -> kick_existing_session(account.id, username) end end defp add_default_items(character_id, top, bottom, shoes, weapon, _job_type) do # Add equipped items Context.create_inventory_item(character_id, :equipped, %{ item_id: top, position: -5, quantity: 1 }) if bottom > 0 do Context.create_inventory_item(character_id, :equipped, %{ item_id: bottom, position: -6, quantity: 1 }) end Context.create_inventory_item(character_id, :equipped, %{ item_id: shoes, position: -7, quantity: 1 }) Context.create_inventory_item(character_id, :equipped, %{ item_id: weapon, position: -11, quantity: 1 }) # Add starter potions Context.create_inventory_item(character_id, :use, %{ item_id: 2000013, position: 0, quantity: 100 }) Context.create_inventory_item(character_id, :use, %{ item_id: 2000014, position: 0, quantity: 100 }) :ok end defp add_job_specific_starters(character_id, job_type) do case job_type do 0 -> # Resistance Context.create_inventory_item(character_id, :etc, %{ item_id: 4161001, position: 0, quantity: 1 }) 1 -> # Adventurer Context.create_inventory_item(character_id, :etc, %{ item_id: 4161001, position: 0, quantity: 1 }) 2 -> # Cygnus # Add starter quests Context.set_quest_progress(character_id, 20022, 1, "1") Context.set_quest_progress(character_id, 20010, 1, nil) Context.create_inventory_item(character_id, :etc, %{ item_id: 4161047, position: 0, quantity: 1 }) 3 -> # Aran Context.create_inventory_item(character_id, :etc, %{ item_id: 4161048, position: 0, quantity: 1 }) 4 -> # Evan Context.create_inventory_item(character_id, :etc, %{ item_id: 4161052, position: 0, quantity: 1 }) _ -> :ok end :ok end defp generate_migration_token do # Generate a unique migration token :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) end defp register_migration_token(token, character_id, account_id, channel) do # Store in Redis with TTL for channel server to pick up Odinsea.Database.Redis.setex( "migration:#{token}", 30, # 30 second TTL Jason.encode!(%{ character_id: character_id, account_id: account_id, channel: channel, timestamp: System.system_time(:second) }) ) :ok end defp get_channel_endpoint(channel) do # TODO: Get actual channel IP from World server config # For now, return localhost with calculated port ip = Application.get_env(:odinsea, :channel_ip, "127.0.0.1") base_port = Application.get_env(:odinsea, :channel_base_port, 8585) port = base_port + (channel - 1) {ip, port} end defp format_timestamp(naive_datetime) do NaiveDateTime.to_string(naive_datetime) end end