Files
odinsea-elixir/lib/odinsea/login/handler.ex
2026-02-25 12:26:26 -07:00

914 lines
29 KiB
Elixir

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)
<<opcode::little-16, rest::binary>> = 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