feat(auth): add an admin-managed login allowlist
Lets the admin restrict which pubkeys may log in, enforced server-side at /api/auth/login before a session is issued. Disabled by default; the admin and the bootstrap (no-admin-claimed-yet) case always pass. Manageable via the existing settings UI/API (npub or hex, one per line). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
@@ -8,28 +9,64 @@ interface Settings {
|
|||||||
relays: string[];
|
relays: string[];
|
||||||
public_url: string;
|
public_url: string;
|
||||||
admin_pubkey: string | null;
|
admin_pubkey: string | null;
|
||||||
|
login_allowlist_enabled: boolean;
|
||||||
|
login_allowlist: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const form = reactive({ blossom_url: '', relays: '', public_url: '' });
|
const form = reactive({
|
||||||
|
blossom_url: '',
|
||||||
|
relays: '',
|
||||||
|
public_url: '',
|
||||||
|
login_allowlist_enabled: false,
|
||||||
|
login_allowlist: '',
|
||||||
|
});
|
||||||
const saved = ref(false);
|
const saved = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
|
||||||
|
/** Accepts npub or raw hex, one per line; returns lowercase hex. Throws on anything invalid. */
|
||||||
|
function parseAllowlist(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
if (line.startsWith('npub1')) {
|
||||||
|
const decoded = nip19.decode(line);
|
||||||
|
if (decoded.type !== 'npub') throw new Error(`not an npub: ${line}`);
|
||||||
|
return decoded.data;
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(line)) throw new Error(`not a valid npub or hex pubkey: ${line}`);
|
||||||
|
return line.toLowerCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const s = await api.get<Settings>('/api/settings');
|
const s = await api.get<Settings>('/api/settings');
|
||||||
form.blossom_url = s.blossom_url;
|
form.blossom_url = s.blossom_url;
|
||||||
form.relays = s.relays.join('\n');
|
form.relays = s.relays.join('\n');
|
||||||
form.public_url = s.public_url;
|
form.public_url = s.public_url;
|
||||||
|
form.login_allowlist_enabled = s.login_allowlist_enabled;
|
||||||
|
form.login_allowlist = s.login_allowlist.map((pk) => nip19.npubEncode(pk)).join('\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
saved.value = false;
|
saved.value = false;
|
||||||
|
let allowlist: string[];
|
||||||
|
try {
|
||||||
|
allowlist = parseAllowlist(form.login_allowlist);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = (err as Error).message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api.put('/api/settings', {
|
await api.put('/api/settings', {
|
||||||
blossom_url: form.blossom_url,
|
blossom_url: form.blossom_url,
|
||||||
relays: form.relays.split('\n').map((r) => r.trim()).filter(Boolean),
|
relays: form.relays.split('\n').map((r) => r.trim()).filter(Boolean),
|
||||||
public_url: form.public_url,
|
public_url: form.public_url,
|
||||||
|
login_allowlist_enabled: form.login_allowlist_enabled,
|
||||||
|
login_allowlist: allowlist,
|
||||||
});
|
});
|
||||||
saved.value = true;
|
saved.value = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -62,6 +99,24 @@ async function save() {
|
|||||||
<input id="set-public" v-model="form.public_url" class="input" type="url" required />
|
<input id="set-public" v-model="form.public_url" class="input" type="url" required />
|
||||||
<p class="mt-1 text-xs text-white/30">Used in RSS feed links. Must be reachable by podcast apps.</p>
|
<p class="mt-1 text-xs text-white/30">Used in RSS feed links. Must be reachable by podcast apps.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="border-t border-white/10 pt-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.login_allowlist_enabled" type="checkbox" />
|
||||||
|
Restrict logins to an allowlist
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-white/30">
|
||||||
|
When enabled, only the admin and pubkeys listed below can log in. Everyone else is
|
||||||
|
rejected at login (existing sessions aren't revoked).
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
id="set-allowlist"
|
||||||
|
v-model="form.login_allowlist"
|
||||||
|
class="input mt-2 font-mono text-sm"
|
||||||
|
rows="6"
|
||||||
|
placeholder="npub1... (one per line, npub or hex)"
|
||||||
|
:disabled="!form.login_allowlist_enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<p v-if="!auth.isAdmin" class="rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
|
<p v-if="!auth.isAdmin" class="rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
|
||||||
Only the admin (first account to log in) can change settings.
|
Only the admin (first account to log in) can change settings.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -79,6 +79,78 @@ describe('auth', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('login allowlist', () => {
|
||||||
|
const sk2 = generateSecretKey();
|
||||||
|
const pk2 = getPublicKey(sk2);
|
||||||
|
|
||||||
|
function nip98Header2(url: string, method: string): string {
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 27235,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: '',
|
||||||
|
tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]],
|
||||||
|
},
|
||||||
|
sk2,
|
||||||
|
);
|
||||||
|
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects a non-listed pubkey once the allowlist is enabled, admin still logs in', async () => {
|
||||||
|
const enable = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings',
|
||||||
|
headers: { cookie, 'content-type': 'application/json' },
|
||||||
|
payload: { login_allowlist_enabled: true, login_allowlist: [] },
|
||||||
|
});
|
||||||
|
expect(enable.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const blocked = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') },
|
||||||
|
});
|
||||||
|
expect(blocked.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const adminStillIn = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
headers: { authorization: nip98Header('http://localhost:8095/api/auth/login', 'POST') },
|
||||||
|
});
|
||||||
|
expect(adminStillIn.statusCode).toBe(200);
|
||||||
|
expect(adminStillIn.json().isAdmin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a pubkey once it is added to the allowlist', async () => {
|
||||||
|
const update = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings',
|
||||||
|
headers: { cookie, 'content-type': 'application/json' },
|
||||||
|
payload: { login_allowlist: [pk2] },
|
||||||
|
});
|
||||||
|
expect(update.statusCode).toBe(200);
|
||||||
|
expect(update.json().login_allowlist).toEqual([pk2]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().pubkey).toBe(pk2);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Leave the allowlist disabled so later describe blocks aren't affected.
|
||||||
|
await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings',
|
||||||
|
headers: { cookie, 'content-type': 'application/json' },
|
||||||
|
payload: { login_allowlist_enabled: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('podcasts, episodes, feed', () => {
|
describe('podcasts, episodes, feed', () => {
|
||||||
let podcastId: string;
|
let podcastId: string;
|
||||||
const sha = 'c'.repeat(64);
|
const sha = 'c'.repeat(64);
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export default async function authRoutes(app: FastifyInstance) {
|
|||||||
if (err instanceof Nip98Error) return reply.code(401).send({ error: err.message });
|
if (err instanceof Nip98Error) return reply.code(401).send({ error: err.message });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
if (!settings.isLoginAllowed(pubkey)) {
|
||||||
|
return reply.code(403).send({ error: 'this account is not on the login allowlist' });
|
||||||
|
}
|
||||||
upsertUser.run(pubkey, nowSecs(), nowSecs());
|
upsertUser.run(pubkey, nowSecs(), nowSecs());
|
||||||
settings.claimAdminIfUnset(pubkey);
|
settings.claimAdminIfUnset(pubkey);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const updateSchema = z.object({
|
|||||||
relays: z.array(z.string().regex(/^wss?:\/\//)).optional(),
|
relays: z.array(z.string().regex(/^wss?:\/\//)).optional(),
|
||||||
public_url: z.string().url().optional(),
|
public_url: z.string().url().optional(),
|
||||||
cashu_mint_url: z.string().url().optional(),
|
cashu_mint_url: z.string().url().optional(),
|
||||||
|
login_allowlist_enabled: z.boolean().optional(),
|
||||||
|
login_allowlist: z.array(z.string().regex(/^[0-9a-f]{64}$/i)).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function settingsRoutes(app: FastifyInstance) {
|
export default async function settingsRoutes(app: FastifyInstance) {
|
||||||
@@ -33,11 +35,24 @@ export default async function settingsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
const parsed = updateSchema.safeParse(req.body);
|
const parsed = updateSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
||||||
const { blossom_url, relays, public_url, cashu_mint_url } = parsed.data;
|
const {
|
||||||
|
blossom_url,
|
||||||
|
relays,
|
||||||
|
public_url,
|
||||||
|
cashu_mint_url,
|
||||||
|
login_allowlist_enabled,
|
||||||
|
login_allowlist,
|
||||||
|
} = parsed.data;
|
||||||
if (blossom_url !== undefined) settings.set('blossom_url', blossom_url);
|
if (blossom_url !== undefined) settings.set('blossom_url', blossom_url);
|
||||||
if (relays !== undefined) settings.set('relays', JSON.stringify(relays));
|
if (relays !== undefined) settings.set('relays', JSON.stringify(relays));
|
||||||
if (public_url !== undefined) settings.set('public_url', public_url);
|
if (public_url !== undefined) settings.set('public_url', public_url);
|
||||||
if (cashu_mint_url !== undefined) settings.set('cashu_mint_url', cashu_mint_url);
|
if (cashu_mint_url !== undefined) settings.set('cashu_mint_url', cashu_mint_url);
|
||||||
|
if (login_allowlist_enabled !== undefined) {
|
||||||
|
settings.set('login_allowlist_enabled', login_allowlist_enabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (login_allowlist !== undefined) {
|
||||||
|
settings.set('login_allowlist', JSON.stringify(login_allowlist.map((pk) => pk.toLowerCase())));
|
||||||
|
}
|
||||||
return settings.all();
|
return settings.all();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface Settings {
|
|||||||
public_url: string;
|
public_url: string;
|
||||||
admin_pubkey: string | null;
|
admin_pubkey: string | null;
|
||||||
cashu_mint_url: string;
|
cashu_mint_url: string;
|
||||||
|
login_allowlist_enabled: boolean;
|
||||||
|
login_allowlist: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
@@ -32,6 +34,8 @@ export class SettingsService {
|
|||||||
public_url: this.get('public_url') ?? this.config.PUBLIC_URL,
|
public_url: this.get('public_url') ?? this.config.PUBLIC_URL,
|
||||||
admin_pubkey: this.get('admin_pubkey'),
|
admin_pubkey: this.get('admin_pubkey'),
|
||||||
cashu_mint_url: this.get('cashu_mint_url') ?? this.config.CASHU_MINT_URL_DEFAULT,
|
cashu_mint_url: this.get('cashu_mint_url') ?? this.config.CASHU_MINT_URL_DEFAULT,
|
||||||
|
login_allowlist_enabled: this.get('login_allowlist_enabled') === 'true',
|
||||||
|
login_allowlist: JSON.parse(this.get('login_allowlist') ?? '[]'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,4 +68,17 @@ export class SettingsService {
|
|||||||
isAdmin(pubkey: string): boolean {
|
isAdmin(pubkey: string): boolean {
|
||||||
return this.get('admin_pubkey') === pubkey;
|
return this.get('admin_pubkey') === pubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a pubkey may log in. Disabled by default (everyone allowed). When enabled,
|
||||||
|
* the admin and no-admin-claimed-yet bootstrap case always pass, otherwise the pubkey
|
||||||
|
* must be in the allowlist.
|
||||||
|
*/
|
||||||
|
isLoginAllowed(pubkey: string): boolean {
|
||||||
|
if (this.get('login_allowlist_enabled') !== 'true') return true;
|
||||||
|
if (!this.get('admin_pubkey')) return true;
|
||||||
|
if (this.isAdmin(pubkey)) return true;
|
||||||
|
const list: string[] = JSON.parse(this.get('login_allowlist') ?? '[]');
|
||||||
|
return list.includes(pubkey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user