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.
This commit is contained in:
commit
a2ce3a69fd
14 changed files with 3018 additions and 0 deletions
445
server/auth.lua
Normal file
445
server/auth.lua
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue