v4: users sign in to one gerbeur instance with their account on another — topic instances, each with its own admins, domain and data, had no way to share accounts. Sign-in is federated over a subset of OpenID Connect, the authorization code flow with PKCE: each instance is an identity provider for its own users and a client for the others', and signs what it tells them with an Ed25519 key kept in federation_keys and published at /.well-known/jwks.json, beside its metadata at /.well-known/openid-configuration. Only sign-in is federated: an account from another instance is a local users row linked to (issuer, subject) in user_identities, with no password (password_hash ''), so sessions, roles, permissions, the socket and every foreign key work as before.
A remote user is name@host here, in users.username, in paths (/@alice@music.example) and in mentions, through one handle rule in shared/validation.ts that paths, MENTION_RE and Markdown all build on; Markdown undoes GFM's email autolink of a handle. Their name, email and avatar come from their home at each sign-in and by its signed events (federation_outbox, delivered by the federation job with backoff): a rename at home leaves the old handle an alias here, and a password change or a withdrawn grant ends their sessions here. Their password, email, avatar and name can't change here (assertLocalUser), nor can they sign in elsewhere from here. An admin keeps the trusted and blocked instances on /manage (federation:manage): a trusted instance's users sign in from a button on the login page, a blocked one's can't and are signed out at once, and any other's join with an invite from the register page. The /authorize page asks the user once per instance, and /settings lists the instances their account signs in to, each disconnectable; /login?next= brings a signed-out visitor back to /authorize. Emails stay unique among local accounts only, and migration 0024 adds the tables, makes that index partial and reserves authorize and federation as category slugs. remote/gerbeur-instance.ts is the only way to other instances: https and public addresses only, no redirects, no certificate fallback, unless GERBEUR_FEDERATION_ALLOW_PRIVATE lets two instances on one machine talk. 298 backend tests and 702 frontend tests pass, and deno check, deno fmt, deno lint, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked end to end with two built instances on one machine: sign-in through the other's login and consent pages, consent skipped once granted and asked again after disconnecting, a rename and a password change at home reaching the other, and blocking; screenshots of the login, consent, profile, settings and manage pages at 1280 and 390px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import type {
|
||||
UserSuggestion,
|
||||
} from "../../shared/user.ts";
|
||||
import {
|
||||
isRemoteUser,
|
||||
isUserRow,
|
||||
toPublicUser,
|
||||
type User,
|
||||
@@ -24,7 +25,13 @@ import {
|
||||
userRowToApi,
|
||||
} from "../model/user.ts";
|
||||
import { db, transaction } from "../db/connection.ts";
|
||||
import { closeUserSockets } from "./ws-service.ts";
|
||||
import {
|
||||
broadcastUserUpdated,
|
||||
closeUserSockets,
|
||||
updateClientAvatar,
|
||||
} from "./ws-service.ts";
|
||||
import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts";
|
||||
import { AVATARS_DIR } from "../config.ts";
|
||||
|
||||
import { hashPassword } from "../lib/password.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
@@ -116,6 +123,39 @@ export async function createUser(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the account of a user from another instance, named `username`
|
||||
* (their handle here, `name@host`), with no password, for
|
||||
* `federation-service.ts`, which links it to their identity there in the same
|
||||
* transaction. `inviterId` is whoever invited them, or null when their
|
||||
* instance is trusted.
|
||||
*
|
||||
* @throws {APIException} 409 when the handle is or was someone else's.
|
||||
*/
|
||||
export function createRemoteUser(
|
||||
request: { username: string; email: string },
|
||||
inviterId: string | null,
|
||||
): User {
|
||||
const userId = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
assertUsernameAvailable(request.username);
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
|
||||
VALUES (?, ?, '', 'user', ?, ?, ?);`,
|
||||
).run(
|
||||
userId,
|
||||
request.username,
|
||||
createdAt.toISOString(),
|
||||
inviterId,
|
||||
request.email,
|
||||
);
|
||||
} catch (err) {
|
||||
throw uniqueViolationToConflict(err);
|
||||
}
|
||||
return getUserById(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emails `user` the welcome message, if email is configured. The register
|
||||
* route calls it once the account exists, and doesn't wait: a slow or failing
|
||||
@@ -146,18 +186,28 @@ export function getUserById(userId: string): User {
|
||||
|
||||
/**
|
||||
* Returns the user's live role and their last password change, before which
|
||||
* their tokens don't count, or undefined when the user is gone. `lib/auth.ts`
|
||||
* checks every session and media token against it.
|
||||
* their tokens don't count, or undefined when the user is gone, or comes from
|
||||
* an instance an admin blocked. `lib/auth.ts` checks every session and media
|
||||
* token against it.
|
||||
*/
|
||||
export function getSessionState(
|
||||
userId: string,
|
||||
): { role: Role; tokensValidAfter: string | null } | undefined {
|
||||
const row = db.prepare(
|
||||
`SELECT role, tokens_valid_after FROM users WHERE id = ?;`,
|
||||
`SELECT u.role, u.tokens_valid_after, p.status AS peer_status
|
||||
FROM users u
|
||||
LEFT JOIN user_identities ui ON ui.user_id = u.id
|
||||
LEFT JOIN federation_peers p ON p.issuer = ui.issuer
|
||||
WHERE u.id = ?;`,
|
||||
).get(userId) as
|
||||
| { role: Role; tokens_valid_after: string | null }
|
||||
| {
|
||||
role: Role;
|
||||
tokens_valid_after: string | null;
|
||||
peer_status: string | null;
|
||||
}
|
||||
| undefined;
|
||||
return row && {
|
||||
if (!row || row.peer_status === "blocked") return undefined;
|
||||
return {
|
||||
role: row.role,
|
||||
tokensValidAfter: row.tokens_valid_after,
|
||||
};
|
||||
@@ -225,6 +275,19 @@ export function renameUser(userId: string, username: string): User {
|
||||
`Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`,
|
||||
);
|
||||
}
|
||||
return setUsername(userId, username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Names user `userId` `username`, keeping the old name as their alias as
|
||||
* {@link renameUser} describes, and returns them. The caller has checked the
|
||||
* name: {@link renameUser} checks a local one, and `federation-service.ts`
|
||||
* the handle of a user renamed on their home instance.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user, 409 when the name is
|
||||
* or was someone else's.
|
||||
*/
|
||||
export function setUsername(userId: string, username: string): User {
|
||||
const user = getUserById(userId);
|
||||
if (username === user.username) return user;
|
||||
|
||||
@@ -246,13 +309,29 @@ export function renameUser(userId: string, username: string): User {
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a 409 when an account other than `exceptUserId` has `email`, in any
|
||||
* case. Password resets are looked up by email, so an address must belong to
|
||||
* one account only.
|
||||
* Throws a 400 when `user`'s account lives on another instance
|
||||
* ({@link isRemoteUser}), whose password, email, avatar and name are that
|
||||
* instance's to change. The routes that change them call it first.
|
||||
*/
|
||||
export function assertLocalUser(user: User): void {
|
||||
if (isRemoteUser(user)) {
|
||||
throw new APIException(
|
||||
400,
|
||||
"This account is managed on the instance it comes from",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a 409 when a local account other than `exceptUserId` has `email`, in
|
||||
* any case. Password resets are looked up by email, so an address must belong
|
||||
* to one local account only; an account from another instance may share it,
|
||||
* as the same person may have both.
|
||||
*/
|
||||
function assertEmailAvailable(email: string, exceptUserId?: string): void {
|
||||
const row = db.prepare(
|
||||
`SELECT id FROM users WHERE lower(email) = lower(?) AND id != ?;`,
|
||||
`SELECT id FROM users
|
||||
WHERE lower(email) = lower(?) AND id != ? AND password_hash != '';`,
|
||||
).get(email, exceptUserId ?? "");
|
||||
if (row) {
|
||||
throw new APIException(409, "Email already in use");
|
||||
@@ -271,12 +350,13 @@ function uniqueViolationToConflict(err: unknown): unknown {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user whose email is `email`, in any case, or null, for
|
||||
* `requestPasswordReset`.
|
||||
* Returns the local user whose email is `email`, in any case, or null, for
|
||||
* `requestPasswordReset`. An account from another instance has no password
|
||||
* here to reset.
|
||||
*/
|
||||
export function getUserByEmail(email: string): User | null {
|
||||
const userRow = db.prepare(
|
||||
`${USER_SELECT} WHERE lower(u.email) = lower(?)`,
|
||||
`${USER_SELECT} WHERE lower(u.email) = lower(?) AND u.password_hash != ''`,
|
||||
).get(email);
|
||||
|
||||
if (!userRow || !isUserRow(userRow)) return null;
|
||||
@@ -385,13 +465,7 @@ export async function updateUser(
|
||||
throw new APIException(404, "User not found");
|
||||
}
|
||||
|
||||
if (password) {
|
||||
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
||||
now.toISOString(),
|
||||
userId,
|
||||
);
|
||||
closeUserSockets(userId, "Password changed");
|
||||
}
|
||||
if (password) revokeSessions(userId, now, "Password changed");
|
||||
|
||||
if (updatedUser.description) {
|
||||
linkAttachments(updatedUser.description, "user", userId);
|
||||
@@ -400,14 +474,54 @@ export async function updateUser(
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends every session of user `userId` opened before `at`, closing their
|
||||
* sockets with `reason`: their tokens stop counting
|
||||
* (`users.tokens_valid_after`, which `getSessionState` reads). A password
|
||||
* change calls it, and so does the sign-out of a user from another instance,
|
||||
* when that instance says so or an admin blocks it.
|
||||
*/
|
||||
export function revokeSessions(userId: string, at: Date, reason: string): void {
|
||||
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
||||
at.toISOString(),
|
||||
userId,
|
||||
);
|
||||
closeUserSockets(userId, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the image `data` user `userId`'s avatar and returns them, for an
|
||||
* upload (`PUT /api/me/avatar`) and for the avatar a user from another
|
||||
* instance has there (`federation-service.ts`). Presence alone would update
|
||||
* only the online list, so it also sends `user_updated`, which reaches every
|
||||
* avatar on screen, its bumped `updatedAt` busting their cache.
|
||||
*
|
||||
* @throws {APIException} 400 when `data` isn't an image an avatar may be, 404
|
||||
* when there is no such user.
|
||||
*/
|
||||
export async function setUserAvatar(
|
||||
userId: string,
|
||||
data: Uint8Array,
|
||||
): Promise<User> {
|
||||
const mime = validateImageUpload(data);
|
||||
// Written before the record announces it, or clients would fetch it too
|
||||
// early.
|
||||
await replaceUploadedFile(`${AVATARS_DIR}/${userId}`, data);
|
||||
updateUserAvatar(userId, mime);
|
||||
updateClientAvatar(userId, mime);
|
||||
const user = getUserById(userId);
|
||||
broadcastUserUpdated(toPublicUser(user));
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that user `userId` now has an avatar of type `mime`, once
|
||||
* `PUT /api/me/avatar` has written the file. It bumps `updated_at`, which
|
||||
* {@link setUserAvatar} has written the file. It bumps `updated_at`, which
|
||||
* the client uses to reload the image.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user.
|
||||
*/
|
||||
export function updateUserAvatar(userId: string, mime: string): void {
|
||||
function updateUserAvatar(userId: string, mime: string): void {
|
||||
const result = db.prepare(
|
||||
`UPDATE users SET avatar_mime = ?, updated_at = ? WHERE id = ?`,
|
||||
).run(mime, new Date().toISOString(), userId);
|
||||
|
||||
Reference in New Issue
Block a user