ic-web/server/auth.lua

446 lines
18 KiB
Lua
Raw Permalink Normal View History

-- ─────────────────────────────────────────────────────────────────────────────
-- 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