-- ───────────────────────────────────────────────────────────────────────────── -- 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 @ pflegt die Seiten seiner Domaene und -- legt dort weitere Zugaenge an -- editor @ 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