541 lines
15 KiB
Go
541 lines
15 KiB
Go
package login
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"eq2emu/internal/common/opcodes"
|
|
"eq2emu/internal/packets"
|
|
"eq2emu/internal/udp"
|
|
)
|
|
|
|
// ClientState represents the current state of a login client
|
|
type ClientState int
|
|
|
|
const (
|
|
ClientStateNew ClientState = iota
|
|
ClientStateAuthenticating
|
|
ClientStateAuthenticated
|
|
ClientStateCharacterSelect
|
|
ClientStateDisconnected
|
|
)
|
|
|
|
// String returns the string representation of the client state
|
|
func (cs ClientState) String() string {
|
|
switch cs {
|
|
case ClientStateNew:
|
|
return "New"
|
|
case ClientStateAuthenticating:
|
|
return "Authenticating"
|
|
case ClientStateAuthenticated:
|
|
return "Authenticated"
|
|
case ClientStateCharacterSelect:
|
|
return "CharacterSelect"
|
|
case ClientStateDisconnected:
|
|
return "Disconnected"
|
|
default:
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
// Client represents a connected login client
|
|
type Client struct {
|
|
connection *udp.Connection
|
|
database *LoginDB
|
|
|
|
// Client information
|
|
accountID int32
|
|
accountName string
|
|
accountEmail string
|
|
accessLevel int16
|
|
|
|
// Authentication data
|
|
loginKey string
|
|
sessionKey string
|
|
ipAddress string
|
|
|
|
// Client state
|
|
state ClientState
|
|
clientVersion uint16
|
|
protocolVersion uint16
|
|
|
|
// Character data
|
|
characters []CharacterInfo
|
|
|
|
// Timing
|
|
connectTime time.Time
|
|
lastActivity time.Time
|
|
|
|
// Synchronization
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// CharacterInfo represents character information for the character select screen
|
|
type CharacterInfo struct {
|
|
ID int32 `json:"id"`
|
|
AccountID int32 `json:"account_id"`
|
|
Name string `json:"name"`
|
|
Race int8 `json:"race"`
|
|
Class int8 `json:"class"`
|
|
Gender int8 `json:"gender"`
|
|
Level int16 `json:"level"`
|
|
Zone int32 `json:"zone"`
|
|
ZoneInstance int32 `json:"zone_instance"`
|
|
ServerID int16 `json:"server_id"`
|
|
LastPlayed int64 `json:"last_played"`
|
|
CreatedDate int64 `json:"created_date"`
|
|
DeletedDate int64 `json:"deleted_date"`
|
|
|
|
// Appearance data
|
|
ModelType int16 `json:"model_type"`
|
|
SogaModelType int16 `json:"soga_model_type"`
|
|
HeadType int16 `json:"head_type"`
|
|
SogaHeadType int16 `json:"soga_head_type"`
|
|
WingType int16 `json:"wing_type"`
|
|
ChestType int16 `json:"chest_type"`
|
|
LegsType int16 `json:"legs_type"`
|
|
SogaChestType int16 `json:"soga_chest_type"`
|
|
SogaLegsType int16 `json:"soga_legs_type"`
|
|
HairType int16 `json:"hair_type"`
|
|
FacialHairType int16 `json:"facial_hair_type"`
|
|
SogaHairType int16 `json:"soga_hair_type"`
|
|
SogaFacialHairType int16 `json:"soga_facial_hair_type"`
|
|
|
|
// Colors
|
|
HairTypeColor int16 `json:"hair_type_color"`
|
|
HairTypeHighlight int16 `json:"hair_type_highlight"`
|
|
HairColor1 int16 `json:"hair_color1"`
|
|
HairColor2 int16 `json:"hair_color2"`
|
|
HairHighlight int16 `json:"hair_highlight"`
|
|
EyeColor1 int16 `json:"eye_color1"`
|
|
EyeColor2 int16 `json:"eye_color2"`
|
|
SkinColor int16 `json:"skin_color"`
|
|
|
|
// Soga colors
|
|
SogaHairTypeColor int16 `json:"soga_hair_type_color"`
|
|
SogaHairTypeHighlight int16 `json:"soga_hair_type_highlight"`
|
|
SogaHairColor1 int16 `json:"soga_hair_color1"`
|
|
SogaHairColor2 int16 `json:"soga_hair_color2"`
|
|
SogaHairHighlight int16 `json:"soga_hair_highlight"`
|
|
SogaEyeColor1 int16 `json:"soga_eye_color1"`
|
|
SogaEyeColor2 int16 `json:"soga_eye_color2"`
|
|
SogaSkinColor int16 `json:"soga_skin_color"`
|
|
|
|
// Additional data
|
|
CurrentLanguage int8 `json:"current_language"`
|
|
ChosenLanguage int8 `json:"chosen_language"`
|
|
}
|
|
|
|
// NewClient creates a new login client
|
|
func NewClient(conn *udp.Connection, db *LoginDB) *Client {
|
|
now := time.Now()
|
|
|
|
client := &Client{
|
|
connection: conn,
|
|
database: db,
|
|
state: ClientStateNew,
|
|
connectTime: now,
|
|
lastActivity: now,
|
|
ipAddress: conn.GetClientAddr().String(),
|
|
characters: make([]CharacterInfo, 0),
|
|
}
|
|
|
|
return client
|
|
}
|
|
|
|
// HandlePacket processes an incoming packet from the client
|
|
func (c *Client) HandlePacket(data []byte) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.lastActivity = time.Now()
|
|
|
|
if len(data) < 2 {
|
|
return fmt.Errorf("packet too short")
|
|
}
|
|
|
|
// Extract opcode
|
|
opcode := binary.LittleEndian.Uint16(data[:2])
|
|
payload := data[2:]
|
|
|
|
log.Printf("Client %s: Received opcode 0x%04x, size %d", c.ipAddress, opcode, len(payload))
|
|
|
|
switch opcode {
|
|
case opcodes.OpLoginRequestMsg:
|
|
return c.handleLoginRequest(payload)
|
|
case opcodes.OpAllCharactersDescRequestMsg:
|
|
return c.handleCharacterSelectRequest(payload)
|
|
case opcodes.OpPlayCharacterRequestMsg:
|
|
return c.handlePlayCharacterRequest(payload)
|
|
case opcodes.OpDeleteCharacterRequestMsg:
|
|
return c.handleDeleteCharacterRequest(payload)
|
|
case opcodes.OpCreateCharacterRequestMsg:
|
|
return c.handleCreateCharacterRequest(payload)
|
|
default:
|
|
log.Printf("Client %s: Unknown opcode 0x%04x", c.ipAddress, opcode)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// handleLoginRequest processes a login request from the client
|
|
func (c *Client) handleLoginRequest(payload []byte) error {
|
|
if c.state != ClientStateNew {
|
|
return fmt.Errorf("invalid state for login request: %s", c.state)
|
|
}
|
|
|
|
// Parse the packet using the proper packet system
|
|
clientVersion := uint32(562) // Assume version 562 for now
|
|
data, err := packets.ParsePacketFields(payload, "LoginRequest", clientVersion)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse login request: %w", err)
|
|
}
|
|
|
|
// Extract fields based on the XML definition
|
|
accessCode := getStringField(data, "accesscode")
|
|
unknown1 := getStringField(data, "unknown1")
|
|
username := getStringField(data, "username")
|
|
password := getStringField(data, "password")
|
|
|
|
log.Printf("Login request - Username: %s, Access: %s, Unknown: %s",
|
|
username, accessCode, unknown1)
|
|
|
|
c.state = ClientStateAuthenticating
|
|
|
|
// Authenticate user
|
|
return c.authenticateUser(username, password)
|
|
}
|
|
|
|
// Helper function to safely extract string fields from packet data
|
|
func getStringField(data map[string]any, field string) string {
|
|
if val, ok := data[field]; ok {
|
|
if str, ok := val.(string); ok {
|
|
return str
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// authenticateUser authenticates the user credentials
|
|
func (c *Client) authenticateUser(username, password string) error {
|
|
username = strings.TrimSpace(strings.Trim(username, "\x00"))
|
|
|
|
// Hash the password (MD5 for EQ2 compatibility)
|
|
hasher := md5.New()
|
|
hasher.Write([]byte(password))
|
|
hashedPassword := fmt.Sprintf("%x", hasher.Sum(nil))
|
|
|
|
// Query database for account
|
|
account, err := c.database.GetLoginAccount(username, hashedPassword)
|
|
if err != nil {
|
|
log.Printf("Authentication failed for %s: %v", username, err)
|
|
return c.sendLoginReply(0, "Invalid username or password")
|
|
}
|
|
|
|
// Check account status
|
|
if account.Status != "Active" {
|
|
log.Printf("Account %s is not active: %s", username, account.Status)
|
|
return c.sendLoginReply(0, "Account is suspended")
|
|
}
|
|
|
|
// Store account information
|
|
c.accountID = account.ID
|
|
c.accountName = account.Username
|
|
c.accountEmail = account.Email
|
|
c.accessLevel = account.AccessLevel
|
|
c.state = ClientStateAuthenticated
|
|
|
|
log.Printf("User %s (ID: %d) authenticated successfully", username, account.ID)
|
|
|
|
// Generate session key
|
|
c.sessionKey = c.generateSessionKey()
|
|
|
|
// Update last login
|
|
c.database.UpdateLastLogin(c.accountID, c.ipAddress)
|
|
|
|
// Send successful login reply
|
|
return c.sendLoginReply(1, "Welcome to EverQuest II")
|
|
}
|
|
|
|
// sendLoginReply sends a login reply to the client
|
|
func (c *Client) sendLoginReply(success int8, message string) error {
|
|
// Build login reply using the packet system
|
|
clientVersion := uint32(562) // TODO: Track actual client version
|
|
|
|
data := map[string]any{
|
|
"login_response": success,
|
|
"unknown": message,
|
|
}
|
|
|
|
if success == 1 {
|
|
data["account_id"] = c.accountID
|
|
// Add other required fields for successful login
|
|
data["parental_control_flag"] = uint8(0)
|
|
data["parental_control_timer"] = uint32(0)
|
|
|
|
// TODO: Add more fields as needed based on client version
|
|
}
|
|
|
|
packet, err := packets.BuildPacket("LoginReplyMsg", data, clientVersion, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build login reply packet: %w", err)
|
|
}
|
|
|
|
// Send the packet
|
|
appPacket := &udp.ApplicationPacket{
|
|
Data: packet,
|
|
}
|
|
c.connection.SendPacket(appPacket)
|
|
|
|
// If login successful, send character list
|
|
if success == 1 {
|
|
if err := c.loadCharacters(); err != nil {
|
|
log.Printf("Failed to load characters: %v", err)
|
|
}
|
|
return c.sendCharacterList()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadCharacters loads the character list for this account
|
|
func (c *Client) loadCharacters() error {
|
|
characters, err := c.database.GetCharacters(c.accountID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load characters: %w", err)
|
|
}
|
|
|
|
c.characters = make([]CharacterInfo, len(characters))
|
|
for i, char := range characters {
|
|
c.characters[i] = CharacterInfo{
|
|
ID: char.ID,
|
|
AccountID: char.AccountID,
|
|
Name: strings.TrimSpace(strings.Trim(char.Name, "\x00")),
|
|
Race: char.Race,
|
|
Class: char.Class,
|
|
Gender: char.Gender,
|
|
Level: char.Level,
|
|
Zone: char.Zone,
|
|
ServerID: char.ServerID,
|
|
LastPlayed: char.LastPlayed,
|
|
CreatedDate: char.CreatedDate,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
|
|
// handleCharacterSelectRequest handles character selection
|
|
func (c *Client) handleCharacterSelectRequest(payload []byte) error {
|
|
if c.state != ClientStateAuthenticated {
|
|
return fmt.Errorf("invalid state for character select: %s", c.state)
|
|
}
|
|
|
|
c.state = ClientStateCharacterSelect
|
|
|
|
// Send character list
|
|
if err := c.loadCharacters(); err != nil {
|
|
return fmt.Errorf("failed to load characters: %w", err)
|
|
}
|
|
|
|
return c.sendCharacterList()
|
|
}
|
|
|
|
// sendCharacterList sends the character list to the client
|
|
func (c *Client) sendCharacterList() error {
|
|
// For now, send character profiles individually using CharSelectProfile packet
|
|
// In the real implementation, this would be sent as part of LoginReplyMsg
|
|
|
|
for _, char := range c.characters {
|
|
data := map[string]any{
|
|
"version": uint32(562),
|
|
"charid": uint32(char.ID),
|
|
"server_id": uint32(char.ServerID),
|
|
"name": char.Name,
|
|
"unknown": uint8(0),
|
|
"race": uint8(char.Race),
|
|
"class": uint8(char.Class),
|
|
"gender": uint8(char.Gender),
|
|
"level": uint32(char.Level),
|
|
"zone": "Qeynos Harbor", // TODO: Get actual zone name
|
|
"unknown1": uint32(0),
|
|
"unknown2": uint32(0),
|
|
"created_date": uint32(char.CreatedDate),
|
|
"last_played": uint32(char.LastPlayed),
|
|
"unknown3": uint32(0),
|
|
"unknown4": uint32(0),
|
|
"zonename2": "Qeynos Harbor",
|
|
"zonedesc": "The Harbor District",
|
|
"unknown5": uint32(0),
|
|
"server_name": "EQ2Go Server",
|
|
"account_id": uint32(c.accountID),
|
|
}
|
|
|
|
// Add appearance data with defaults for now
|
|
// TODO: Load actual character appearance data
|
|
|
|
clientVersion := uint32(562)
|
|
packet, err := packets.BuildPacket("CharSelectProfile", data, clientVersion, 0)
|
|
if err != nil {
|
|
log.Printf("Failed to build character profile packet: %v", err)
|
|
continue
|
|
}
|
|
|
|
appPacket := &udp.ApplicationPacket{
|
|
Data: packet,
|
|
}
|
|
c.connection.SendPacket(appPacket)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handlePlayCharacterRequest handles play character request
|
|
func (c *Client) handlePlayCharacterRequest(payload []byte) error {
|
|
if c.state != ClientStateCharacterSelect && c.state != ClientStateAuthenticated {
|
|
return fmt.Errorf("invalid state for play character: %s", c.state)
|
|
}
|
|
|
|
if len(payload) < 8 {
|
|
return fmt.Errorf("play character packet too short")
|
|
}
|
|
|
|
characterID := binary.LittleEndian.Uint32(payload[:4])
|
|
serverID := binary.LittleEndian.Uint16(payload[4:6])
|
|
|
|
log.Printf("Play character request - Character: %d, Server: %d", characterID, serverID)
|
|
|
|
// Find character
|
|
var character *CharacterInfo
|
|
for i := range c.characters {
|
|
if c.characters[i].ID == int32(characterID) {
|
|
character = &c.characters[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if character == nil {
|
|
return fmt.Errorf("character %d not found", characterID)
|
|
}
|
|
|
|
// TODO: Forward to world server
|
|
return c.sendPlayCharacterReply(character, "127.0.0.1", 9000)
|
|
}
|
|
|
|
// sendPlayCharacterReply sends play character reply to client
|
|
func (c *Client) sendPlayCharacterReply(character *CharacterInfo, worldIP string, worldPort int) error {
|
|
data := map[string]any{
|
|
"response": uint8(1), // Success
|
|
"server": worldIP,
|
|
"port": uint16(worldPort),
|
|
"account_id": uint32(c.accountID),
|
|
"access_code": uint32(12345), // TODO: Generate proper access code
|
|
}
|
|
|
|
clientVersion := uint32(562)
|
|
packet, err := packets.BuildPacket("PlayResponse", data, clientVersion, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build play response packet: %w", err)
|
|
}
|
|
|
|
appPacket := &udp.ApplicationPacket{
|
|
Data: packet,
|
|
}
|
|
c.connection.SendPacket(appPacket)
|
|
return nil
|
|
}
|
|
|
|
// handleDeleteCharacterRequest handles character deletion
|
|
func (c *Client) handleDeleteCharacterRequest(payload []byte) error {
|
|
// TODO: Implement character deletion
|
|
return nil
|
|
}
|
|
|
|
// handleCreateCharacterRequest handles character creation
|
|
func (c *Client) handleCreateCharacterRequest(payload []byte) error {
|
|
// TODO: Implement character creation
|
|
return nil
|
|
}
|
|
|
|
// generateSessionKey generates a unique session key for this client
|
|
func (c *Client) generateSessionKey() string {
|
|
now := time.Now()
|
|
data := fmt.Sprintf("%d-%s-%d", c.accountID, c.ipAddress, now.Unix())
|
|
|
|
hasher := md5.New()
|
|
hasher.Write([]byte(data))
|
|
|
|
return fmt.Sprintf("%x", hasher.Sum(nil))
|
|
}
|
|
|
|
// Disconnect disconnects the client
|
|
func (c *Client) Disconnect(reason string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.state == ClientStateDisconnected {
|
|
return
|
|
}
|
|
|
|
log.Printf("Disconnecting client %s: %s", c.ipAddress, reason)
|
|
|
|
c.state = ClientStateDisconnected
|
|
if c.connection != nil {
|
|
c.connection.Close()
|
|
}
|
|
}
|
|
|
|
// GetState returns the current client state (thread-safe)
|
|
func (c *Client) GetState() ClientState {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.state
|
|
}
|
|
|
|
// GetAccountID returns the account ID (thread-safe)
|
|
func (c *Client) GetAccountID() int32 {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.accountID
|
|
}
|
|
|
|
// GetAccountName returns the account name (thread-safe)
|
|
func (c *Client) GetAccountName() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.accountName
|
|
}
|
|
|
|
// GetIPAddress returns the client IP address
|
|
func (c *Client) GetIPAddress() string {
|
|
return c.ipAddress
|
|
}
|
|
|
|
// GetConnection returns the UDP connection
|
|
func (c *Client) GetConnection() *udp.Connection {
|
|
return c.connection
|
|
}
|
|
|
|
// GetConnectTime returns when the client connected
|
|
func (c *Client) GetConnectTime() time.Time {
|
|
return c.connectTime
|
|
}
|
|
|
|
// GetLastActivity returns the last activity time
|
|
func (c *Client) GetLastActivity() time.Time {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.lastActivity
|
|
}
|
|
|
|
// IsTimedOut returns whether the client has timed out
|
|
func (c *Client) IsTimedOut(timeout time.Duration) bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return time.Since(c.lastActivity) > timeout
|
|
} |