Auth-herding: roller, kontoadmin, token-revokering + søkbar planliste + neon
Auth (app-modus):
- Roller admin/standard. Admin oppretter/sletter kontoer, resetter passord,
endrer roller (konto-modal). Eksisterende brukere → admin ved migrering;
bootstrap-bruker via proxy-bypass → admin. Siste admin vernet.
- Tilbakekalling via per-bruker epoke i tokenet: passordbytte, admin-reset,
«Logg ut alle enheter» og sletting dreper alle aktive sesjoner. Token-format
endret (3- → 4-delt) — alle logges ut én gang ved oppgradering.
Dagsplan: søkbar/velgbar planliste («📋 Planer») avledet fra planIndex —
finn plan på navn/dato/person uten å gjette dato.
GUI: login-neon trukket inn i hovedappen (header/knapper/KPI-kanter), og
login-siden fikk mer tech (HUD-hjørner, glitch-tittel, scanlines).
40 integrasjonstester (6 nye), alle grønne. README + CLAUDE.md oppdatert.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f6268440d9
commit
3f69cd4248
9 changed files with 518 additions and 36 deletions
|
|
@ -49,6 +49,15 @@ class Auth {
|
|||
if (d && typeof d === 'object' && !Array.isArray(d)) this.users = d;
|
||||
} catch { /* ingen brukere ennå */ }
|
||||
|
||||
// Migrering: eksisterende brukere (med passord) blir admin; alle får epoke.
|
||||
let changed = false;
|
||||
for (const u of Object.values(this.users)) {
|
||||
if (!u || typeof u !== 'object') continue;
|
||||
if (u.password && u.role !== 'admin' && u.role !== 'standard') { u.role = 'admin'; changed = true; }
|
||||
if (typeof u.epoch !== 'number') { u.epoch = 1; changed = true; }
|
||||
}
|
||||
if (changed) { try { this._save(); } catch { /* ignorer */ } }
|
||||
|
||||
// Hemmelighet: env > persistert fil > nygenerert (persistert).
|
||||
if (!this.secret) {
|
||||
try { this.secret = fs.readFileSync(this.secretFile, 'utf8').trim(); } catch { /* genereres under */ }
|
||||
|
|
@ -68,12 +77,64 @@ class Auth {
|
|||
|
||||
hasPassword(user) { return !!(user && this.users[user] && this.users[user].password); }
|
||||
anyPassword() { return Object.values(this.users).some(u => u && u.password); }
|
||||
exists(user) { return !!(user && this.users[user]); }
|
||||
role(user) { return (this.users[user] && this.users[user].role) || null; }
|
||||
isAdmin(user) { return this.role(user) === 'admin'; }
|
||||
epochOf(user) { return (this.users[user] && this.users[user].epoch) || 1; }
|
||||
adminCount() { return Object.values(this.users).filter(u => u && u.role === 'admin').length; }
|
||||
|
||||
setPassword(user, password) {
|
||||
// role: 'admin' | 'standard'. Udefinert → behold eksisterende, ellers 'standard'.
|
||||
// Hver passord-setting bumper epoke, så alle gamle tokens for brukeren dør.
|
||||
setPassword(user, password, role) {
|
||||
if (!user) throw new Error('Mangler bruker');
|
||||
if (typeof password !== 'string' || password.length < 6) throw new Error('Passord må være minst 6 tegn');
|
||||
this.users[user] = { ...(this.users[user] || {}), password: hashPassword(password), updated: new Date().toISOString() };
|
||||
const ex = this.users[user] || {};
|
||||
this.users[user] = {
|
||||
...ex,
|
||||
password: hashPassword(password),
|
||||
role: role || ex.role || 'standard',
|
||||
epoch: (ex.epoch || 0) + 1,
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
this._save();
|
||||
return this.users[user];
|
||||
}
|
||||
|
||||
// Tilbakekall alle aktive sesjoner for brukeren (logg ut overalt).
|
||||
bumpEpoch(user) {
|
||||
if (!this.users[user]) return;
|
||||
this.users[user].epoch = (this.users[user].epoch || 0) + 1;
|
||||
this._save();
|
||||
}
|
||||
|
||||
setRole(user, role) {
|
||||
if (!this.users[user]) throw new Error('Ukjent bruker');
|
||||
if (role !== 'admin' && role !== 'standard') throw new Error('Ugyldig rolle');
|
||||
if (role !== 'admin' && this.users[user].role === 'admin' && this.adminCount() <= 1) {
|
||||
throw new Error('Kan ikke fjerne den siste administratoren');
|
||||
}
|
||||
this.users[user].role = role;
|
||||
this._save();
|
||||
}
|
||||
|
||||
createUser(user, password, role = 'standard') {
|
||||
if (this.users[user]) throw new Error('Brukeren finnes allerede');
|
||||
return this.setPassword(user, password, role === 'admin' ? 'admin' : 'standard');
|
||||
}
|
||||
|
||||
deleteUser(user) {
|
||||
if (!this.users[user]) throw new Error('Ukjent bruker');
|
||||
if (this.users[user].role === 'admin' && this.adminCount() <= 1) {
|
||||
throw new Error('Kan ikke slette den siste administratoren');
|
||||
}
|
||||
delete this.users[user];
|
||||
this._save();
|
||||
}
|
||||
|
||||
listUsers() {
|
||||
return Object.entries(this.users)
|
||||
.map(([user, u]) => ({ user, role: u.role || 'standard', hasPassword: !!u.password, updated: u.updated || null }))
|
||||
.sort((a, b) => a.user.localeCompare(b.user));
|
||||
}
|
||||
|
||||
verify(user, password) {
|
||||
|
|
@ -81,9 +142,9 @@ class Auth {
|
|||
return !!(u && u.password && verifyPassword(password, u.password));
|
||||
}
|
||||
|
||||
// Signert sesjons-token. user → "<b64url(user)>.<exp>.<hmac>"
|
||||
// Signert sesjons-token. user → "<b64url(user)>.<exp>.<epoch>.<hmac>"
|
||||
makeToken(user) {
|
||||
const payload = `${Buffer.from(String(user)).toString('base64url')}.${Date.now() + this.ttlMs}`;
|
||||
const payload = `${Buffer.from(String(user)).toString('base64url')}.${Date.now() + this.ttlMs}.${this.epochOf(user)}`;
|
||||
const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
|
@ -91,16 +152,18 @@ class Auth {
|
|||
verifyToken(token) {
|
||||
if (typeof token !== 'string') return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = `${parts[0]}.${parts[1]}`;
|
||||
if (parts.length !== 4) return null; // gamle 3-delte tokens er ugyldige etter herding
|
||||
const payload = `${parts[0]}.${parts[1]}.${parts[2]}`;
|
||||
const expected = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url');
|
||||
const a = Buffer.from(parts[2]);
|
||||
const a = Buffer.from(parts[3]);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
const exp = Number(parts[1]);
|
||||
if (!exp || Date.now() > exp) return null;
|
||||
try { return Buffer.from(parts[0], 'base64url').toString('utf8') || null; }
|
||||
catch { return null; }
|
||||
if (!Number(parts[1]) || Date.now() > Number(parts[1])) return null;
|
||||
let user;
|
||||
try { user = Buffer.from(parts[0], 'base64url').toString('utf8'); } catch { return null; }
|
||||
if (!user || !this.users[user]) return null; // konto slettet → token dør
|
||||
if (Number(parts[2]) !== this.epochOf(user)) return null; // epoke bumpet → token dør
|
||||
return user;
|
||||
}
|
||||
|
||||
ttlSeconds() { return Math.floor(this.ttlMs / 1000); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue