ic-web/server/auth.lua
Bjoern Flessing a2ce3a69fd IC-Webhosting: Domaenen, Webseiten und Postfaecher ueber Anmeldungen
Ein IC-Hostinganbieter im Spiel. Er vergibt Domaenen, richtet je Domaene einen
Zugang ein, und die Inhaber pflegen darauf ihre Webseite.

Die gesamte Verwaltung haengt an einer Anmeldung, nicht an Job oder
Serverrechten. Ein Zugang ist zugleich ein Postfach: wer sich anmeldet,
bekommt das Postfach dieser Adresse. Mehrere Personen koennen denselben Zugang
benutzen und dasselbe Postfach gleichzeitig offen haben - ein Firmenpostfach
gehoert der Firma, nicht einer Person.

Drei Ebenen:
  superadmin  vergibt Domaenen und setzt je Domaene einen Admin ein
  admin       pflegt die Seiten seiner Domaene und legt dort Zugaenge an
  editor      pflegt nur die Seiten

Seiteninhalte sind kein HTML, sondern typisierte Bloecke. Die Seiten werden im
NUI des IC-Computers angezeigt, also im selben Kontext wie dieser selbst - wer
dort Markup einschleusen kann, kann auch dessen Callbacks aufrufen. Ein
Blockmodell schliesst das konstruktiv aus, statt es filtern zu muessen.

Passwoerter liegen gesalzen und gehasht in der Datenbank, gerechnet wird in der
Datenbank. Fuenf Fehlversuche je Benutzername sperren den Namen kurzzeitig.

Enthaelt README.md mit Einrichtung und Anbindung an den IC-Computer sowie
ANLEITUNG.md fuer die Bedienung im Spiel.
2026-08-09 11:50:53 +00:00

445 lines
18 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- ─────────────────────────────────────────────────────────────────────────────
-- ic-web Zugaenge und Anmeldung
--
-- Die gesamte Verwaltung laeuft ueber Anmeldungen, nicht ueber Job oder
-- Serverrechte. Ein Zugang ist eine Mailadresse mit Passwort:
--
-- superadmin admin@liveinvader.ls legt Domaenen an und setzt je Domaene
-- einen Admin ein
-- admin <name>@<domaene> pflegt die Seiten seiner Domaene und
-- legt dort weitere Zugaenge an
-- editor <name>@<domaene> pflegt nur die Seiten
--
-- Wer angemeldet ist, bekommt zugleich das Postfach dieser Adresse. Mehrere
-- Personen koennen sich mit demselben Zugang anmelden und dasselbe Postfach
-- offen haben das ist beabsichtigt, ein Firmenpostfach gehoert der Firma
-- und nicht einer Person.
--
-- Das Passwort ist ein IC-Passwort. Es liegt gesalzen und gehasht in der
-- Datenbank und wird nie an den Client geschickt, aber es ist keine
-- Sicherheitsgrenze gegen jemanden mit Datenbankzugriff.
-- ─────────────────────────────────────────────────────────────────────────────
ICWeb = ICWeb or {}
ICWeb.Auth = {}
local A = ICWeb.Auth
local DB = ICWeb.DB
local FW = ICWeb.FW
-- source → { username, role, domain, since, lastSeen }
local sessions = {}
-- source → { [username] = true }: zusaetzlich per Passwort geoeffnete
-- Postfaecher. Siehe A.OpenMailbox weiter unten.
local openMailboxes = {}
-- ── Hilfen ──────────────────────────────────────────────────────────────────
math.randomseed(os.time())
local function randomSalt()
local out = {}
for i = 1, 32 do
out[i] = ('%x'):format(math.random(0, 15))
end
return table.concat(out)
end
-- Benutzername ist immer eine Mailadresse. Gibt (user, domain) oder nil.
function A.SplitUsername(raw)
local name = tostring(raw or ''):lower():gsub('%s', '')
local user, domain = name:match('^([a-z0-9%.%-_]+)@([a-z0-9%-]+%.[a-z]+)$')
if not user then return nil end
return user, domain, name
end
-- ── Anmelden ────────────────────────────────────────────────────────────────
-- Gibt (session, nil) oder (nil, fehlertext) zurueck.
function A.Login(src, rawUsername, password)
local user, domain, username = A.SplitUsername(rawUsername)
if not user then return nil, 'Benutzername muss eine Adresse sein, z. B. admin@liveinvader.ls' end
if type(password) ~= 'string' or password == '' then
return nil, 'Kein Passwort angegeben.'
end
-- Erst die Bremse, dann die Pruefung: sonst laesst sich der Zaehler
-- umgehen, indem man einfach weiter probiert.
local fails = DB.CountLoginFails(username, Config.Login.windowSeconds)
if fails >= Config.Login.maxAttempts then
return nil, ('Zu viele Fehlversuche. Bitte %d Minuten warten.')
:format(math.ceil(Config.Login.windowSeconds / 60))
end
local account = DB.CheckPassword(username, password)
if not account then
DB.AddLoginFail(username, FW.GetIdentifier(src))
ICWeb.Log.Write(src, 'login.failed', { username = username }, 'warn')
return nil, 'Benutzername oder Passwort falsch.'
end
if not ICWeb.Bool(account.active) then return nil, 'Dieser Zugang ist gesperrt.' end
DB.ClearLoginFails(username)
DB.TouchLogin(account.id)
-- Alte Anmeldung dieses Spielers sauber beenden, damit das vorherige
-- Postfach nicht offen bleibt.
A.Logout(src)
sessions[src] = {
id = account.id,
username = account.username,
role = account.role,
domain = account.domain,
display = account.display_name,
mustChange = ICWeb.Bool(account.must_change),
since = os.time(),
lastSeen = os.time(),
}
A.AttachMailbox(src, account.username, account.display_name)
ICWeb.Log.Write(src, 'login.ok', { username = username, role = account.role })
return A.Public(src), nil
end
function A.Logout(src)
local session = sessions[src]
if not session then return end
-- Das Postfach der Anmeldung nur schliessen, wenn es nicht zusaetzlich
-- ueber "Postfach hinzufuegen" offen ist. Sonst nimmt das Abmelden von der
-- Verwaltung dem Mailclient ein Postfach weg, das dort eigenstaendig
-- freigeschaltet wurde.
if not (openMailboxes[src] and openMailboxes[src][session.username]) then
A.DetachMailbox(src, session.username)
end
sessions[src] = nil
end
-- ── Postfach an die Anmeldung koppeln ───────────────────────────────────────
-- Der Zugriff wird beim Anmelden erteilt und beim Abmelden wieder entzogen.
-- Dadurch haengt das Postfach am Zugang und nicht am Charakter, und mehrere
-- Angemeldete koennen es gleichzeitig offen haben.
--
-- Beide Richtungen laufen ueber pcall: das Postfach ist eine Dreingabe zur
-- Anmeldung. Wenn ic-mail zickt, soll man sich trotzdem anmelden und abmelden
-- koennen nur eben ohne Postfach.
function A.AttachMailbox(src, address, displayName)
if GetResourceState('ic-mail') ~= 'started' then return end
local identifier = FW.GetIdentifier(src)
if not identifier then return end
local ok, err = pcall(function()
exports['ic-mail']:EnsureSharedAddress(address, displayName, 'ic-web')
exports['ic-mail']:GrantAddressAccess(address, identifier, FW.GetName(src), 'send', 'ic-web')
end)
if not ok then
print(('[ic-web] ^3Postfach %s konnte nicht geöffnet werden: %s^7')
:format(tostring(address), tostring(err)))
end
end
function A.DetachMailbox(src, address)
if GetResourceState('ic-mail') ~= 'started' then return end
local identifier = FW.GetIdentifier(src)
if not identifier then return end
local ok, err = pcall(function()
exports['ic-mail']:RevokeAddressAccess(address, identifier)
end)
if not ok then
print(('[ic-web] ^3Postfach %s konnte nicht geschlossen werden: %s^7')
:format(tostring(address), tostring(err)))
end
end
-- ── Weitere Postfaecher oeffnen ─────────────────────────────────────────────
--
-- Im Mailclient soll man mehrere Postfaecher gleichzeitig haben: das eigene,
-- das der Firma, das der Behoerde. Jedes davon ist ein Zugang mit Passwort,
-- also wird es genauso freigeschaltet wie die Anmeldung selbst nur ohne
-- Verwaltungsrechte. Wer die Zugangsdaten kennt, sieht das Postfach.
--
-- Bewusst getrennt von der Anmeldung an der Verwaltung: man kann zehn
-- Postfaecher offen haben und trotzdem nur fuer eines Seiten pflegen duerfen.
--
-- source → { [username] = true } (oben deklariert)
-- Gibt (true, nil) oder (false, fehlertext) zurueck.
function A.OpenMailbox(src, rawUsername, password)
local user, _, username = A.SplitUsername(rawUsername)
if not user then return false, 'Der Postfachname ist eine Adresse, z. B. presse@weazel.ls' end
if type(password) ~= 'string' or password == '' then
return false, 'Kein Passwort angegeben.'
end
-- Dieselbe Bremse wie bei der Anmeldung: sonst waere das Freischalten
-- eines Postfachs der bequemere Weg, Passwoerter durchzuprobieren.
if DB.CountLoginFails(username, Config.Login.windowSeconds) >= Config.Login.maxAttempts then
return false, ('Zu viele Fehlversuche. Bitte %d Minuten warten.')
:format(math.ceil(Config.Login.windowSeconds / 60))
end
local account = DB.CheckPassword(username, password)
if not account then
DB.AddLoginFail(username, FW.GetIdentifier(src))
ICWeb.Log.Write(src, 'mailbox.failed', { username = username }, 'warn')
return false, 'Adresse oder Passwort falsch.'
end
if not ICWeb.Bool(account.active) then return false, 'Dieser Zugang ist gesperrt.' end
local boxes = openMailboxes[src] or {}
local count = 0
for _ in pairs(boxes) do count = count + 1 end
if not boxes[account.username] and count >= (Config.MaxOpenMailboxes or 10) then
return false, ('Höchstens %d zusätzliche Postfächer gleichzeitig.')
:format(Config.MaxOpenMailboxes or 10)
end
DB.ClearLoginFails(username)
boxes[account.username] = true
openMailboxes[src] = boxes
A.AttachMailbox(src, account.username, account.display_name)
ICWeb.Log.Write(src, 'mailbox.opened', { username = account.username })
return true, nil
end
function A.CloseMailbox(src, rawUsername)
local _, _, username = A.SplitUsername(rawUsername)
if not username then return false, 'Unbekanntes Postfach.' end
local boxes = openMailboxes[src]
if not boxes or not boxes[username] then
return false, 'Dieses Postfach ist hier nicht geöffnet.'
end
boxes[username] = nil
A.DetachMailbox(src, username)
ICWeb.Log.Write(src, 'mailbox.closed', { username = username })
return true, nil
end
-- Welche Postfaecher hat dieser Spieler ueber ein Passwort geoeffnet? Der
-- Mailclient braucht das, um genau bei diesen ein "Entfernen" anzubieten
-- das eigene und dienstlich zugewiesene Postfaecher gehoeren ihm nicht.
function A.ListOpenMailboxes(src)
local out = {}
for username in pairs(openMailboxes[src] or {}) do out[#out + 1] = username end
table.sort(out)
return out
end
-- ── Sitzung ─────────────────────────────────────────────────────────────────
function A.Get(src)
local session = sessions[src]
if not session then return nil end
local idle = Config.Login.idleSeconds or 1800
if idle > 0 and (os.time() - session.lastSeen) > idle then
A.Logout(src)
return nil
end
session.lastSeen = os.time()
return session
end
-- Was der Client ueber seine Anmeldung erfahren darf. Kein Passwort, kein Salt.
function A.Public(src)
local session = A.Get(src)
if not session then return nil end
return {
id = session.id,
username = session.username,
role = session.role,
domain = session.domain,
display = session.display,
mustChange = session.mustChange == true,
}
end
-- ── Rechte ──────────────────────────────────────────────────────────────────
function A.IsSuperadmin(src)
local session = A.Get(src)
return session ~= nil and session.role == 'superadmin'
end
-- Seiten dieser Domaene pflegen.
function A.CanEdit(src, domain)
local session = A.Get(src)
if not session then return false end
if session.role == 'superadmin' then return true end
return session.domain == tostring(domain or ''):lower()
end
-- Zugaenge dieser Domaene verwalten. Editoren duerfen das nicht sonst koennte
-- sich jeder Editor selbst zum Admin machen.
function A.CanManageAccounts(src, domain)
local session = A.Get(src)
if not session then return false end
if session.role == 'superadmin' then return true end
return session.role == 'admin' and session.domain == tostring(domain or ''):lower()
end
-- ── Zugaenge anlegen und aendern ────────────────────────────────────────────
-- Gibt (accountId, nil) oder (nil, fehlertext) zurueck.
function A.CreateAccount(data)
local user, domain, username = A.SplitUsername(data.username)
if not user then return nil, 'Benutzername muss eine Adresse sein, z. B. presse@weazel.ls' end
if data.role ~= 'admin' and data.role ~= 'editor' and data.role ~= 'superadmin' then
return nil, 'Unbekannte Rolle.'
end
-- Der superadmin haengt an keiner Domaene, alle anderen an genau ihrer.
if data.role ~= 'superadmin' and domain ~= data.domain then
return nil, 'Der Benutzername muss auf die eigene Domäne enden.'
end
local pwErr = A.CheckPassword(data.password)
if pwErr then return nil, pwErr end
if DB.GetAccountByUsername(username) then
return nil, 'Diesen Benutzernamen gibt es bereits.'
end
-- Nicht als `role == 'superadmin' and nil or domain` schreiben: in Lua
-- faellt der nil-Zweig durch und man bekaeme doch die Domaene.
local accountDomain = domain
if data.role == 'superadmin' then accountDomain = nil end
local id = DB.CreateAccount({
username = username,
domain = accountDomain,
role = data.role,
display_name = tostring(data.display_name or user):sub(1, 120),
salt = randomSalt(),
password = data.password,
must_change = data.must_change and true or false,
created_by = data.created_by,
})
if not id then return nil, 'Speichern fehlgeschlagen.' end
-- Das Postfach gibt es ab sofort, auch wenn sich noch niemand angemeldet hat.
if GetResourceState('ic-mail') == 'started' then
exports['ic-mail']:EnsureSharedAddress(username, data.display_name or user, 'ic-web')
end
return id, nil
end
-- Gibt nil zurueck, wenn das Passwort taugt, sonst den Grund.
function A.CheckPassword(password)
if type(password) ~= 'string' then return 'Kein Passwort angegeben.' end
if #password < (Config.Login.minPasswordLength or 6) then
return ('Das Passwort muss mindestens %d Zeichen haben.')
:format(Config.Login.minPasswordLength or 6)
end
if #password > 100 then return 'Das Passwort ist zu lang.' end
return nil
end
-- Nach dem Wechsel des eigenen Passworts soll die Aufforderung verschwinden,
-- ohne dass sich der Zugang neu anmelden muss.
function A.ClearMustChange(src)
local session = sessions[src]
if session then session.mustChange = false end
end
function A.SetPassword(accountId, password)
local err = A.CheckPassword(password)
if err then return false, err end
DB.SetPassword(accountId, randomSalt(), password)
return true, nil
end
-- ── Aufraeumen ──────────────────────────────────────────────────────────────
AddEventHandler('playerDropped', function()
local src = source
for username in pairs(openMailboxes[src] or {}) do
A.DetachMailbox(src, username)
end
openMailboxes[src] = nil
A.Logout(src)
end)
-- Beim Herunterfahren wird NICHT mehr aufgeraeumt.
--
-- A.Logout ruft ic-mail auf, und ic-mail wartet dort auf eine Datenbankantwort.
-- Waehrend die Resource stoppt, laesst sich die wartende Koroutine nicht mehr
-- fortsetzen: die Abfrage laeuft durch, das Ergebnis kommt aber ins Leere und
-- der Server meldet "Execution of function reference in script host failed"
-- zugeordnet zu ic-mail, obwohl der Ausloeser hier liegt.
--
-- Aufgeraeumt wird stattdessen beim Start (A.CleanupStaleMailAccess). Das
-- deckt auch den Fall ab, den ein Stop-Handler nie erwischt: den Absturz.
AddEventHandler('onResourceStop', function(res)
if res ~= GetCurrentResourceName() then return end
sessions, openMailboxes = {}, {}
end)
-- Zugriffsrechte, die frueher einmal ueber eine Anmeldung erteilt wurden.
-- Beim Start gibt es keine Anmeldung, also darf auch kein Recht mehr offen sein.
function A.CleanupStaleMailAccess()
if GetResourceState('ic-mail') ~= 'started' then return end
local ok, removed = pcall(function()
return exports['ic-mail']:RevokeAccessGrantedBy('ic-web')
end)
if ok and tonumber(removed) and tonumber(removed) > 0 then
print(('[ic-web] %d verwaiste Postfachzugriffe entfernt.'):format(tonumber(removed)))
end
end
-- ── Ersteinrichtung ─────────────────────────────────────────────────────────
-- Ohne einen ersten Zugang kaeme niemand je in die Verwaltung hinein.
function A.EnsureMainAdmin()
local cfg = Config.MainAdmin
if DB.GetAccountByUsername(cfg.username) then return end
-- Die Domaene des Anbieters muss es geben, sonst haengt das Postfach ins Leere.
if GetResourceState('ic-mail') == 'started' then
local _, realmDomain = A.SplitUsername(cfg.username)
if realmDomain and not exports['ic-mail']:GetRealmByName(realmDomain) then
exports['ic-mail']:CreateRealm({
realm = realmDomain,
type = 'system',
display_name = cfg.providerName or realmDomain,
})
end
end
local id, err = A.CreateAccount({
username = cfg.username,
role = 'superadmin',
display_name = cfg.providerName or 'Administration',
password = cfg.initialPassword,
must_change = true,
created_by = 'system',
})
if not id then
print(('[ic-web] ^1Hauptzugang konnte nicht angelegt werden: %s^7'):format(tostring(err)))
return
end
print('[ic-web] ^3Hauptzugang angelegt:^7 ' .. cfg.username)
print('[ic-web] ^3Startpasswort:^7 ' .. cfg.initialPassword
.. ' bitte nach der ersten Anmeldung ändern (/webpasswd).')
end