508 lines
16 KiB
GDScript
508 lines
16 KiB
GDScript
extends Node
|
|
## AuthManager - Centralized authentication handling for Nakama
|
|
## Supports: Guest (device ID), Email/Password, Social (Google, Apple, Facebook)
|
|
|
|
# Signals
|
|
signal auth_started
|
|
signal auth_completed(success: bool, user_data: Dictionary)
|
|
signal auth_failed(error: String)
|
|
signal session_restored
|
|
signal logged_out
|
|
|
|
# Auth modes
|
|
enum AuthMode {GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, STEAM, CUSTOM}
|
|
|
|
# User data
|
|
var current_user: Dictionary = {}
|
|
var is_authenticated: bool = false
|
|
var is_guest: bool = false
|
|
var auth_mode: AuthMode = AuthMode.GUEST
|
|
|
|
# Session persistence
|
|
const SESSION_FILE := "user://auth_session.dat"
|
|
const CREDENTIALS_FILE := "user://auth_credentials.dat"
|
|
|
|
# Encryption key for session storage (replace with your own!)
|
|
const ENCRYPTION_KEY := "tekton_secret_key_change_me_123"
|
|
|
|
func _ready() -> void:
|
|
# Try to restore session on startup
|
|
call_deferred("_try_restore_session")
|
|
|
|
# =============================================================================
|
|
# Session Persistence
|
|
# =============================================================================
|
|
|
|
func _try_restore_session() -> void:
|
|
if not FileAccess.file_exists(SESSION_FILE):
|
|
print("[AuthManager] No saved session found")
|
|
return
|
|
|
|
var file := FileAccess.open_encrypted_with_pass(SESSION_FILE, FileAccess.READ, ENCRYPTION_KEY)
|
|
if not file:
|
|
print("[AuthManager] Could not open session file")
|
|
return
|
|
|
|
var session_data = file.get_var()
|
|
file.close()
|
|
|
|
if not session_data or not session_data is Dictionary:
|
|
return
|
|
|
|
var token: String = session_data.get("token", "")
|
|
var refresh_token: String = session_data.get("refresh_token", "")
|
|
var saved_auth_mode: int = session_data.get("auth_mode", AuthMode.GUEST)
|
|
|
|
if saved_auth_mode == AuthMode.GUEST:
|
|
print("[AuthManager] Ignoring guest session auto-restore to force login screen.")
|
|
return
|
|
|
|
if token.is_empty():
|
|
return
|
|
|
|
print("[AuthManager] Attempting to restore session...")
|
|
|
|
# Try to restore the session
|
|
var session = NakamaClient.restore_session(token)
|
|
|
|
if session.is_expired():
|
|
# Try to refresh
|
|
if refresh_token:
|
|
var refreshed: NakamaSession = await NakamaManager.client.session_refresh_async(session)
|
|
if not refreshed.is_exception():
|
|
session = refreshed
|
|
_save_session(session, saved_auth_mode)
|
|
else:
|
|
var err_msg: String = refreshed.get_exception().message
|
|
print("[AuthManager] Session refresh failed (%s) — need to re-login" % err_msg)
|
|
return
|
|
else:
|
|
print("[AuthManager] Session expired, need to re-login")
|
|
return
|
|
|
|
# Session valid, connect
|
|
NakamaManager.session = session
|
|
auth_mode = saved_auth_mode as AuthMode
|
|
is_guest = auth_mode == AuthMode.GUEST
|
|
|
|
var socket_success := await _connect_socket()
|
|
if not socket_success:
|
|
# Clean up so manual login starts from a blank slate
|
|
push_warning("[AuthManager] Session restore failed — socket could not connect. Resetting state.")
|
|
NakamaManager.session = null
|
|
NakamaManager.socket = null
|
|
return
|
|
|
|
await _load_user_profile()
|
|
is_authenticated = true
|
|
emit_signal("session_restored")
|
|
emit_signal("auth_completed", true, current_user)
|
|
print("[AuthManager] Session restored successfully")
|
|
|
|
func _save_session(session: NakamaSession, mode: AuthMode) -> void:
|
|
var file := FileAccess.open_encrypted_with_pass(SESSION_FILE, FileAccess.WRITE, ENCRYPTION_KEY)
|
|
if file:
|
|
file.store_var({
|
|
"token": session.token,
|
|
"refresh_token": session.refresh_token,
|
|
"auth_mode": mode,
|
|
"user_id": session.user_id
|
|
})
|
|
file.close()
|
|
|
|
func clear_session() -> void:
|
|
if FileAccess.file_exists(SESSION_FILE):
|
|
DirAccess.remove_absolute(SESSION_FILE)
|
|
if FileAccess.file_exists(CREDENTIALS_FILE):
|
|
DirAccess.remove_absolute(CREDENTIALS_FILE)
|
|
|
|
# =============================================================================
|
|
# Guest Authentication
|
|
# =============================================================================
|
|
|
|
func login_as_guest() -> bool:
|
|
emit_signal("auth_started")
|
|
auth_mode = AuthMode.GUEST
|
|
is_guest = true
|
|
|
|
# Use device ID for guest auth
|
|
var device_id := _get_device_id()
|
|
print("[AuthManager] Guest login with device: ", device_id.substr(0, 8) + "...")
|
|
|
|
var session: NakamaSession = await NakamaManager.client.authenticate_device_async(device_id, null, true)
|
|
|
|
if session.is_exception():
|
|
var error: String = session.get_exception().message
|
|
emit_signal("auth_failed", error)
|
|
return false
|
|
|
|
NakamaManager.session = session
|
|
_save_session(session, AuthMode.GUEST)
|
|
|
|
var socket_success := await _connect_socket()
|
|
if not socket_success:
|
|
emit_signal("auth_failed", "Failed to connect to game server")
|
|
return false
|
|
|
|
await _load_user_profile()
|
|
is_authenticated = true
|
|
|
|
emit_signal("auth_completed", true, current_user)
|
|
return true
|
|
|
|
func _get_device_id() -> String:
|
|
# Try to load saved device ID for consistent guest identity
|
|
var id_file := "user://device_id.txt"
|
|
if FileAccess.file_exists(id_file):
|
|
var file_read := FileAccess.open(id_file, FileAccess.READ)
|
|
if file_read:
|
|
var saved_id := file_read.get_as_text().strip_edges()
|
|
file_read.close()
|
|
if not saved_id.is_empty():
|
|
return saved_id
|
|
|
|
# Generate new device ID
|
|
var device_id := OS.get_unique_id()
|
|
if device_id.is_empty():
|
|
device_id = str(randi()) + str(Time.get_ticks_msec())
|
|
|
|
# Save for future use
|
|
var file_write := FileAccess.open(id_file, FileAccess.WRITE)
|
|
if file_write:
|
|
file_write.store_string(device_id)
|
|
file_write.close()
|
|
|
|
return device_id
|
|
|
|
# =============================================================================
|
|
# Email/Password Authentication
|
|
# =============================================================================
|
|
|
|
func login_with_email(email: String, password: String, remember: bool = true) -> bool:
|
|
emit_signal("auth_started")
|
|
auth_mode = AuthMode.EMAIL
|
|
is_guest = false
|
|
|
|
print("[AuthManager] Email login: ", email)
|
|
|
|
var session: NakamaSession = await NakamaManager.client.authenticate_email_async(email, password, null, false)
|
|
|
|
if session.is_exception():
|
|
var error: String = session.get_exception().message
|
|
emit_signal("auth_failed", error)
|
|
return false
|
|
|
|
NakamaManager.session = session
|
|
|
|
if remember:
|
|
_save_session(session, AuthMode.EMAIL)
|
|
|
|
var socket_success := await _connect_socket()
|
|
if not socket_success:
|
|
emit_signal("auth_failed", "Failed to connect to game server")
|
|
return false
|
|
|
|
await _load_user_profile()
|
|
is_authenticated = true
|
|
|
|
emit_signal("auth_completed", true, current_user)
|
|
return true
|
|
|
|
func register_with_email(email: String, password: String, username: String = "") -> bool:
|
|
emit_signal("auth_started")
|
|
auth_mode = AuthMode.EMAIL
|
|
is_guest = false
|
|
|
|
print("[AuthManager] Registering: ", email)
|
|
|
|
# Create account (true = create if not exists)
|
|
var session: NakamaSession = await NakamaManager.client.authenticate_email_async(email, password, username, true)
|
|
|
|
if session.is_exception():
|
|
var error: String = session.get_exception().message
|
|
emit_signal("auth_failed", error)
|
|
return false
|
|
|
|
NakamaManager.session = session
|
|
_save_session(session, AuthMode.EMAIL)
|
|
|
|
var socket_success := await _connect_socket()
|
|
if not socket_success:
|
|
emit_signal("auth_failed", "Failed to connect to game server")
|
|
return false
|
|
|
|
await _load_user_profile()
|
|
is_authenticated = true
|
|
|
|
emit_signal("auth_completed", true, current_user)
|
|
return true
|
|
|
|
# =============================================================================
|
|
# Social Authentication
|
|
# =============================================================================
|
|
|
|
func login_with_google(id_token: String) -> bool:
|
|
emit_signal("auth_started")
|
|
auth_mode = AuthMode.GOOGLE
|
|
is_guest = false
|
|
|
|
print("[AuthManager] Google login...")
|
|
|
|
var session: NakamaSession = await NakamaManager.client.authenticate_google_async(id_token, null, true)
|
|
|
|
if session.is_exception():
|
|
var error: String = session.get_exception().message
|
|
emit_signal("auth_failed", error)
|
|
return false
|
|
|
|
NakamaManager.session = session
|
|
_save_session(session, AuthMode.GOOGLE)
|
|
|
|
var socket_success := await _connect_socket()
|
|
if not socket_success:
|
|
emit_signal("auth_failed", "Failed to connect to game server")
|
|
return false
|
|
|
|
await _load_user_profile()
|
|
is_authenticated = true
|
|
|
|
emit_signal("auth_completed", true, current_user)
|
|
return true
|
|
|
|
func login_with_apple(id_token: String) -> bool:
|
|
emit_signal("auth_started")
|
|
auth_mode = AuthMode.APPLE
|
|
is_guest = false
|
|
|
|
print("[AuthManager] Apple login...")
|
|
|
|
var session: NakamaSession = await NakamaManager.client.authenticate_apple_async(id_token, null, true)
|
|
|
|
if session.is_exception():
|
|
var error: String = session.get_exception().message
|
|
emit_signal("auth_failed", error)
|
|
return false
|
|
|
|
NakamaManager.session = session
|
|
_save_session(session, AuthMode.APPLE)
|
|
|
|
var socket_success := await _connect_socket()
|
|
if not socket_success:
|
|
emit_signal("auth_failed", "Failed to connect to game server")
|
|
return false
|
|
|
|
await _load_user_profile()
|
|
is_authenticated = true
|
|
|
|
emit_signal("auth_completed", true, current_user)
|
|
return true
|
|
|
|
func login_with_facebook(access_token: String) -> bool:
|
|
emit_signal("auth_started")
|
|
auth_mode = AuthMode.FACEBOOK
|
|
is_guest = false
|
|
|
|
print("[AuthManager] Facebook login...")
|
|
|
|
var session: NakamaSession = await NakamaManager.client.authenticate_facebook_async(access_token, null, true)
|
|
|
|
if session.is_exception():
|
|
var error: String = session.get_exception().message
|
|
emit_signal("auth_failed", error)
|
|
return false
|
|
|
|
NakamaManager.session = session
|
|
_save_session(session, AuthMode.FACEBOOK)
|
|
|
|
var socket_success := await _connect_socket()
|
|
if not socket_success:
|
|
emit_signal("auth_failed", "Failed to connect to game server")
|
|
return false
|
|
|
|
await _load_user_profile()
|
|
is_authenticated = true
|
|
|
|
emit_signal("auth_completed", true, current_user)
|
|
return true
|
|
|
|
func login_with_steam() -> bool:
|
|
emit_signal("auth_started")
|
|
auth_mode = AuthMode.STEAM
|
|
is_guest = false
|
|
|
|
print("[AuthManager] Steam login...")
|
|
|
|
# Get Steamworks manager from BackendService
|
|
var backend = get_node_or_null("/root/BackendService")
|
|
if not backend:
|
|
emit_signal("auth_failed", "BackendService not available")
|
|
return false
|
|
|
|
var steamworks = backend.get_steamworks_manager()
|
|
if not steamworks or not steamworks.is_initialized():
|
|
emit_signal("auth_failed", "Steamworks not available. Please launch through Steam.")
|
|
return false
|
|
|
|
# Try full Steam ticket auth first, fall back to Steam ID custom auth for dev/testing
|
|
var session: NakamaSession = await _authenticate_steam_with_fallback(steamworks)
|
|
|
|
if session == null or session.is_exception():
|
|
var error: String = "Steam authentication failed"
|
|
if session != null:
|
|
error = session.get_exception().message
|
|
emit_signal("auth_failed", error)
|
|
return false
|
|
|
|
NakamaManager.session = session
|
|
_save_session(session, AuthMode.STEAM)
|
|
|
|
var socket_success := await _connect_socket()
|
|
if not socket_success:
|
|
emit_signal("auth_failed", "Failed to connect to game server")
|
|
return false
|
|
|
|
await _load_user_profile()
|
|
is_authenticated = true
|
|
|
|
emit_signal("auth_completed", true, current_user)
|
|
return true
|
|
|
|
func _authenticate_steam_with_fallback(steamworks: Node) -> NakamaSession:
|
|
# Try proper Steam ticket auth first
|
|
var auth_ticket = steamworks.get_auth_session_ticket()
|
|
if not auth_ticket.is_empty():
|
|
print("[AuthManager] Got Steam auth ticket, authenticating with Nakama...")
|
|
var session: NakamaSession = await NakamaManager.client.authenticate_steam_async(auth_ticket, null, true)
|
|
if not session.is_exception():
|
|
return session
|
|
print("[AuthManager] Steam ticket auth failed: %s" % session.get_exception().message)
|
|
print("[AuthManager] Falling back to Steam ID custom auth (dev mode)...")
|
|
|
|
# Fallback: use Steam ID + username to create an email-style account (works without publisher key)
|
|
var steam_id = str(steamworks.get_steam_user_id())
|
|
var steam_name = steamworks.get_steam_user_name()
|
|
if steam_id == "0" or steam_id.is_empty():
|
|
return null
|
|
|
|
# Derive email and password from Steam credentials
|
|
var email = steam_name.to_lower().replace(" ", "_") + "@steam.local"
|
|
var password = steam_name # Default password = Steam username
|
|
var username = steam_name
|
|
|
|
print("[AuthManager] Using Steam email auth: %s (%s)" % [email, username])
|
|
var fallback_session: NakamaSession = await NakamaManager.client.authenticate_email_async(email, password, username, true)
|
|
return fallback_session
|
|
|
|
# =============================================================================
|
|
# Account Linking (Convert Guest to Full Account)
|
|
# =============================================================================
|
|
|
|
func link_email(email: String, password: String) -> bool:
|
|
if not is_authenticated or not NakamaManager.session:
|
|
return false
|
|
|
|
print("[AuthManager] Linking email to guest account...")
|
|
|
|
var result: NakamaAsyncResult = await NakamaManager.client.link_email_async(NakamaManager.session, email, password)
|
|
|
|
if result.is_exception():
|
|
push_error("[AuthManager] Link failed: " + result.get_exception().message)
|
|
return false
|
|
|
|
is_guest = false
|
|
auth_mode = AuthMode.EMAIL
|
|
_save_session(NakamaManager.session, AuthMode.EMAIL)
|
|
|
|
print("[AuthManager] Email linked successfully!")
|
|
return true
|
|
|
|
func link_google(id_token: String) -> bool:
|
|
if not is_authenticated or not NakamaManager.session:
|
|
return false
|
|
|
|
var result: NakamaAsyncResult = await NakamaManager.client.link_google_async(NakamaManager.session, id_token)
|
|
|
|
if result.is_exception():
|
|
return false
|
|
|
|
is_guest = false
|
|
auth_mode = AuthMode.GOOGLE
|
|
_save_session(NakamaManager.session, AuthMode.GOOGLE)
|
|
return true
|
|
|
|
# =============================================================================
|
|
# Logout
|
|
# =============================================================================
|
|
|
|
func logout() -> void:
|
|
print("[AuthManager] Logging out...")
|
|
|
|
# FULL CLEANUP: close socket, leave bridge, clear multiplayer peer
|
|
NakamaManager.cleanup()
|
|
|
|
clear_session()
|
|
|
|
current_user = {}
|
|
is_authenticated = false
|
|
is_guest = false
|
|
NakamaManager.session = null
|
|
|
|
emit_signal("logged_out")
|
|
|
|
# =============================================================================
|
|
# Helper Functions
|
|
# =============================================================================
|
|
|
|
func _connect_socket() -> bool:
|
|
if NakamaManager.socket and NakamaManager.socket.is_connected_to_host():
|
|
if not multiplayer.has_multiplayer_peer() and NakamaManager.bridge:
|
|
multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer)
|
|
return true
|
|
|
|
NakamaManager.socket = Nakama.create_socket_from(NakamaManager.client)
|
|
var result = await NakamaManager.socket.connect_async(NakamaManager.session)
|
|
|
|
if result.is_exception():
|
|
push_error("[AuthManager] Socket connection failed: " + result.get_exception().message)
|
|
return false
|
|
|
|
# Initialize multiplayer bridge
|
|
NakamaManager.bridge = NakamaMultiplayerBridge.new(NakamaManager.socket)
|
|
NakamaManager.bridge.match_joined.connect(NakamaManager._on_bridge_match_joined)
|
|
NakamaManager.bridge.match_join_error.connect(NakamaManager._on_bridge_match_join_error)
|
|
multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer)
|
|
|
|
return true
|
|
|
|
func _load_user_profile() -> void:
|
|
if not NakamaManager.session:
|
|
return
|
|
|
|
var account = await NakamaManager.client.get_account_async(NakamaManager.session)
|
|
|
|
if account.is_exception():
|
|
push_error("[AuthManager] Failed to load account")
|
|
return
|
|
|
|
current_user = {
|
|
"user_id": account.user.id,
|
|
"username": account.user.username,
|
|
"display_name": account.user.display_name if account.user.display_name else account.user.username,
|
|
"avatar_url": account.user.avatar_url,
|
|
"email": account.email,
|
|
"is_guest": is_guest,
|
|
"auth_mode": auth_mode,
|
|
"created_at": account.user.create_time
|
|
}
|
|
|
|
print("[AuthManager] User profile loaded: ", current_user.display_name)
|
|
|
|
func get_display_name() -> String:
|
|
return current_user.get("display_name", "Guest")
|
|
|
|
func get_user_id() -> String:
|
|
return current_user.get("user_id", "")
|
|
|
|
func is_logged_in() -> bool:
|
|
return is_authenticated
|