-- --------------------------------------------------------------------------- -- rp_session - everything between "player opened the server" and "player is -- standing in the world": ban gate, accounts, characters, spawn. -- -- The client drives the UI, but every decision is made here. The client may -- only ever ask; it can never assert that it is logged in, that a character -- belongs to it, or where it spawned. -- --------------------------------------------------------------------------- local sessions = {} -- [src] = { license, ip, accountId, username, role, stage } -- This build of FXServer ships no `hardcap` resource (it only advertises the -- name in /info.json), and the bundled `chat` and `monitor` resources are not -- started, so capacity has to be enforced here - see the deferral below. -- Kept as a guard in case a future artefact does ship one. CreateThread(function() if GetResourceState('hardcap') == 'started' then StopResource('hardcap') print('^5[rp_session]^7 stopped bundled hardcap; capacity is enforced by this resource') end end) local STAGE_AUTH = 'auth' local STAGE_CHARS = 'characters' local STAGE_SPAWN = 'spawn' local STAGE_LIVE = 'live' -- --------------------------------------------------------------------------- -- helpers -- --------------------------------------------------------------------------- local function licenseOf(src) for i = 0, GetNumPlayerIdentifiers(src) - 1 do local id = GetPlayerIdentifier(src, i) if id and id:sub(1, 8) == 'license:' then return id:sub(9) end end return nil end local function hashPassword(pw) local p = promise.new() exports.rp_core:hashPassword(pw, function(hash, err) p:resolve({ hash = hash, err = err }) end) local r = Citizen.Await(p) return r.hash, r.err end local function verifyPassword(pw, stored) local p = promise.new() exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end) local r = Citizen.Await(p) if r.err then print(('^1[rp_session]^7 password verify error: %s'):format(r.err)) end return r.ok == true end local function recordAttempt(identity, ip, success) DB.InsertAsync('INSERT INTO auth_attempts (identity, ip, success) VALUES (?, ?, ?)', { identity, ip or DB.NULL, success and 1 or 0 }) end --- True when this identity has burned through its allowance recently. local function throttled(identity) local n = DB.Scalar( 'SELECT COUNT(*) FROM auth_attempts WHERE identity = ? AND success = 0 AND at > (NOW() - INTERVAL ? SECOND)', { identity, Config.Session.attemptWindowS }) return (tonumber(n) or 0) >= Config.Session.maxAttempts end local function characterRows(accountId) return DB.Query([[ SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance, cash, bank, job, job_grade, playtime, last_played_at FROM characters WHERE account_id = ? AND deleted = 0 ORDER BY slot ASC, id ASC ]], { accountId }) or {} end --- Trim a DB row down to what the character-select screen is allowed to see. local function characterCard(row) return { id = row.id, firstName = row.first_name, lastName = row.last_name, dob = tostring(row.dob):sub(1, 10), gender = row.gender, backstory = row.backstory, cash = math.floor(tonumber(row.cash) or 0), bank = math.floor(tonumber(row.bank) or 0), job = row.job, playtime = math.floor(tonumber(row.playtime) or 0), lastPlayed= row.last_played_at and tostring(row.last_played_at) or nil, appearance= row.appearance and json.decode(row.appearance) or nil, } end local function sessionOf(src, requiredStage) local s = sessions[src] if not s then return nil, 'no session' end if requiredStage and s.stage ~= requiredStage then return nil, 'wrong stage' end return s end -- --------------------------------------------------------------------------- -- connection gate -- --------------------------------------------------------------------------- AddEventHandler('playerConnecting', function(_, _, deferrals) local src = source deferrals.defer() Wait(0) local license = licenseOf(src) if not license then return deferrals.done('Could not read your FiveM licence identifier. Restart FiveM and try again.') end -- Capacity. This replaces the stock `hardcap` resource, which is stopped in -- server.cfg so that everything running here is our own code. local maxClients = tonumber(GetConvar('sv_maxclients', '48')) or 48 local online = #GetPlayers() -- the joining player is not counted yet if online >= maxClients then return deferrals.done( ('The server is full (%d of %d). Try again in a few minutes.'):format(online, maxClients)) end deferrals.update('Checking your record...') if not DB.WaitReady(15000) then return deferrals.done('The server database is not available right now. Try again in a minute.') end local ban = DB.Single([[ SELECT id, ban_reason, ban_until FROM accounts WHERE license = ? AND banned = 1 AND (ban_until IS NULL OR ban_until > NOW()) LIMIT 1 ]], { license }) if ban then local until_ = ban.ban_until and (' until ' .. tostring(ban.ban_until) .. ' UTC') or ' permanently' return deferrals.done(('You are banned%s.\n\nReason: %s') :format(until_, ban.ban_reason or 'no reason recorded')) end -- clear bans that have run out so the account is usable again DB.UpdateAsync([[ UPDATE accounts SET banned = 0, ban_reason = NULL, ban_until = NULL WHERE license = ? AND banned = 1 AND ban_until IS NOT NULL AND ban_until <= NOW() ]], { license }) sessions[src] = { license = license, ip = GetPlayerEndpoint(src), stage = STAGE_AUTH } deferrals.done() end) AddEventHandler('playerDropped', function() sessions[source] = nil end) -- --------------------------------------------------------------------------- -- callbacks: authentication -- --------------------------------------------------------------------------- Core.RegisterCallback('session:state', function(src) local s = sessions[src] if not s then return nil, 'no session' end return { stage = s.stage, serverName = Config.ServerName, maxChars = Config.MaxChars } end) Core.RegisterCallback('auth:register', function(src, data) local s, e = sessionOf(src, STAGE_AUTH) if not s then return nil, e end if type(data) ~= 'table' then return nil, 'malformed request' end local username = Util.trim(data.username or '') local password = data.password or '' local ok, why = Rules.checkUsername(username) if not ok then return nil, why end ok, why = Rules.checkPassword(password) if not ok then return nil, why end if throttled(s.license) then return nil, 'Too many attempts. Wait a few minutes and try again.' end local taken = DB.Scalar('SELECT id FROM accounts WHERE username = ? LIMIT 1', { username }) if taken then recordAttempt(s.license, s.ip, false) return nil, 'That username is already taken' end local hash, herr = hashPassword(password) if not hash then print(('^1[rp_session]^7 hashing failed: %s'):format(tostring(herr))) return nil, 'Could not create the account, try again' end local id, ierr = DB.Insert( 'INSERT INTO accounts (username, password_hash, license, last_login_at, last_ip) VALUES (?, ?, ?, NOW(), ?)', { username, hash, s.license, s.ip or DB.NULL }) if not id then -- unique key race: someone registered the same name a moment ago print(('^1[rp_session]^7 register insert failed: %s'):format(tostring(ierr))) return nil, 'That username is already taken' end recordAttempt(s.license, s.ip, true) s.accountId = id s.username = username s.role = 'player' s.stage = STAGE_CHARS exports.rp_core:attachAccount(src, { id = id, username = username, role = 'player', license = s.license }) print(('^2[rp_session]^7 new account %q (id %d) from %s'):format(username, id, GetPlayerName(src))) return { username = username, characters = {}, maxChars = Config.MaxChars } end) Core.RegisterCallback('auth:login', function(src, data) local s, e = sessionOf(src, STAGE_AUTH) if not s then return nil, e end if type(data) ~= 'table' then return nil, 'malformed request' end local username = Util.trim(data.username or '') local password = data.password or '' if username == '' or password == '' then return nil, 'Enter your username and password' end if throttled(s.license) then return nil, 'Too many failed attempts. Wait a few minutes and try again.' end local acc = DB.Single( 'SELECT id, username, password_hash, role, banned, ban_reason, ban_until FROM accounts WHERE username = ? LIMIT 1', { username }) -- Always run a verification so a missing username and a wrong password take -- the same amount of time and cannot be told apart from outside. local stored = acc and acc.password_hash or 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' local good = verifyPassword(password, stored) if not acc or not good then recordAttempt(s.license, s.ip, false) return nil, 'Incorrect username or password' end if acc.banned == 1 then -- re-checked against NOW() so an expired ban does not keep locking them out local active = DB.Scalar( 'SELECT 1 FROM accounts WHERE id = ? AND banned = 1 AND (ban_until IS NULL OR ban_until > NOW())', { acc.id }) if active == 1 then return nil, ('This account is banned. Reason: %s'):format(acc.ban_reason or 'no reason recorded') end end recordAttempt(s.license, s.ip, true) DB.UpdateAsync('UPDATE accounts SET last_login_at = NOW(), last_ip = ?, license = ? WHERE id = ?', { s.ip or DB.NULL, s.license, acc.id }) s.accountId = acc.id s.username = acc.username s.role = acc.role s.stage = STAGE_CHARS exports.rp_core:attachAccount(src, { id = acc.id, username = acc.username, role = acc.role, license = s.license }) local cards = {} for _, row in ipairs(characterRows(acc.id)) do cards[#cards + 1] = characterCard(row) end print(('^2[rp_session]^7 %s logged in as %q'):format(GetPlayerName(src), acc.username)) return { username = acc.username, role = acc.role, characters = cards, maxChars = Config.MaxChars } end) -- --------------------------------------------------------------------------- -- callbacks: characters -- --------------------------------------------------------------------------- Core.RegisterCallback('char:list', function(src) local s, e = sessionOf(src, STAGE_CHARS) if not s then return nil, e end local cards = {} for _, row in ipairs(characterRows(s.accountId)) do cards[#cards + 1] = characterCard(row) end return { characters = cards, maxChars = Config.MaxChars } end) Core.RegisterCallback('char:create', function(src, data) local s, e = sessionOf(src, STAGE_CHARS) if not s then return nil, e end if type(data) ~= 'table' then return nil, 'malformed request' end local first = Util.properName(data.firstName or '') local last = Util.properName(data.lastName or '') local ok, why = Rules.checkName(first) if not ok then return nil, 'First name: ' .. why end ok, why = Rules.checkName(last) if not ok then return nil, 'Last name: ' .. why end ok, why = Rules.checkDob(data.dob or '') if not ok then return nil, 'Date of birth: ' .. why end local gender = (data.gender == 'f') and 'f' or 'm' local backstory = Util.trim(data.backstory or '') if #backstory > 2000 then return nil, 'Backstory is too long (2000 characters max)' end local existing = tonumber(DB.Scalar( 'SELECT COUNT(*) FROM characters WHERE account_id = ? AND deleted = 0', { s.accountId })) or 0 if existing >= Config.MaxChars then return nil, ('You already have %d characters'):format(Config.MaxChars) end local clash = DB.Scalar( 'SELECT id FROM characters WHERE first_name = ? AND last_name = ? LIMIT 1', { first, last }) if clash then return nil, 'Someone on this server already has that name' end local appearance = Appearance.Sanitise(data.appearance) local id, ierr = DB.Insert([[ INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory, appearance, cash, bank) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ]], { s.accountId, existing, first, last, data.dob, gender, backstory ~= '' and backstory or DB.NULL, json.encode(appearance), Config.Money.startingCash, Config.Money.startingBank, }) if not id then print(('^1[rp_session]^7 character insert failed: %s'):format(tostring(ierr))) return nil, 'Could not create that character' end print(('^2[rp_session]^7 %s created character %s %s (id %d)'):format(s.username, first, last, id)) local row = DB.Single([[ SELECT id, slot, first_name, last_name, dob, gender, backstory, appearance, cash, bank, job, job_grade, playtime, last_played_at FROM characters WHERE id = ? ]], { id }) return { character = characterCard(row) } end) Core.RegisterCallback('char:delete', function(src, data) local s, e = sessionOf(src, STAGE_CHARS) if not s then return nil, e end local id = tonumber(type(data) == 'table' and data.id or nil) if not id then return nil, 'malformed request' end -- ownership is checked in the WHERE clause, so a forged id changes nothing local n = DB.Update('UPDATE characters SET deleted = 1 WHERE id = ? AND account_id = ? AND deleted = 0', { id, s.accountId }) if (tonumber(n) or 0) == 0 then return nil, 'That character is not yours' end print(('^3[rp_session]^7 %s deleted character %d'):format(s.username, id)) return { deleted = id } end) Core.RegisterCallback('char:select', function(src, data) local s, e = sessionOf(src, STAGE_CHARS) if not s then return nil, e end local id = tonumber(type(data) == 'table' and data.id or nil) if not id then return nil, 'malformed request' end local row = DB.Single([[ SELECT * FROM characters WHERE id = ? AND account_id = ? AND deleted = 0 ]], { id, s.accountId }) if not row then return nil, 'That character is not yours' end exports.rp_core:attachCharacter(src, row) s.stage = STAGE_SPAWN s.charId = id local last = row.position and json.decode(row.position) or nil local spawns = {} for _, sp in ipairs(Config.SpawnPoints) do spawns[#spawns + 1] = { id = sp.id, label = sp.label, area = sp.area, blurb = sp.blurb, map = sp.map, coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w }, cam = { pos = { x = sp.cam.pos.x, y = sp.cam.pos.y, z = sp.cam.pos.z }, look = { x = sp.cam.look.x, y = sp.cam.look.y, z = sp.cam.look.z } }, } end return { character = characterCard(row), appearance = row.appearance and json.decode(row.appearance) or nil, spawns = spawns, lastPosition = last, } end) -- --------------------------------------------------------------------------- -- callbacks: spawn -- --------------------------------------------------------------------------- Core.RegisterCallback('spawn:confirm', function(src, data) local s, e = sessionOf(src, STAGE_SPAWN) if not s then return nil, e end local choice = type(data) == 'table' and data.id or nil local coords if choice == 'last' then local p = exports.rp_core:getCharacter(src) local last = p and p.position if last and last.x then coords = { x = last.x, y = last.y, z = last.z, h = last.h or 0.0 } end end if not coords then for _, sp in ipairs(Config.SpawnPoints) do if sp.id == choice then coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w } break end end end if not coords then local sp = Config.SpawnPoints[1] coords = { x = sp.coords.x, y = sp.coords.y, z = sp.coords.z, h = sp.coords.w } end s.stage = STAGE_LIVE exports.rp_core:setSpawned(src, true) local char = exports.rp_core:getCharacter(src) print(('^2[rp_session]^7 %s spawned as %s %s'):format(s.username, char.firstName, char.lastName)) TriggerClientEvent('rp:session:spawned', src, coords) return { coords = coords, character = char } end)