diff --git a/backend/api/authentication.ts b/backend/api/authentication.ts index 3e938680c..e71ae4420 100644 --- a/backend/api/authentication.ts +++ b/backend/api/authentication.ts @@ -1048,6 +1048,9 @@ async function routes(app: FastifyInstance) { } }, async (req, reply) => { + if (!WIKI.models.authentication.arePasskeysAllowed()) { + return reply.forbidden('Passkeys are turned off on this wiki.') + } try { const { authOptions, pending } = await WIKI.models.passkeys.startLogin({ hostname: req.hostname, @@ -1112,6 +1115,11 @@ async function routes(app: FastifyInstance) { } }, async (req, reply) => { + // -> The challenge is refused too, so this is the case of one issued before the setting was + // turned off. A passkey stops being a way in the moment it is: nothing here is deleted. + if (!WIKI.models.authentication.arePasskeysAllowed()) { + return reply.forbidden('Passkeys are turned off on this wiki.') + } try { const result = await WIKI.models.passkeys.verifyLogin( { @@ -1465,6 +1473,82 @@ async function routes(app: FastifyInstance) { } ) + /** + * GET THE INSTANCE-WIDE AUTHENTICATION CONFIGURATION + */ + app.get( + '/authentication/config', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Get the instance-wide authentication settings', + description: + 'The settings that hold for every site: whether passkeys may be used, and whether a user may edit their own profile. Which strategies a site offers is part of that site’s configuration instead.', + tags: ['Authentication'], + response: { + 200: { $ref: 'AuthConfig#' } + } + } + }, + async () => { + return WIKI.models.authentication.getConfig() + } + ) + + /** + * UPDATE THE INSTANCE-WIDE AUTHENTICATION CONFIGURATION + */ + app.put<{ Body: Record }>( + '/authentication/config', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Update the instance-wide authentication settings', + description: + 'Accepts any subset of the fields, and applies at once on every instance — nothing here is read at boot. Turning passkeys off leaves the registered ones in place: they cannot be used while it is off, and work again as soon as it is back on.', + tags: ['Authentication'], + body: { $ref: 'AuthConfig#' }, + response: { + 200: { + description: 'Authentication configuration updated successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + } + } + } + } + } + }, + async (req, reply) => { + const patch = WIKI.models.authentication.pickFields(req.body) + if (Object.keys(patch).length < 1) { + return reply.badRequest('No valid authentication setting was provided.') + } + + if (!(await WIKI.models.authentication.updateConfig(patch))) { + return reply.internalServerError('Failed to save the authentication configuration.') + } + + // -> Recorded in full, as the security settings are: neither of these is a secret, and what + // they were set to is exactly what gets asked about after somebody loses a way in + await audit(req, 'admin', 'updateAuthConfig', patch) + + return { + ok: true, + message: 'Authentication configuration saved successfully.' + } + } + ) + /** * LIST AUTHENTICATION MODULES */ diff --git a/backend/api/bootstrap.ts b/backend/api/bootstrap.ts index 644696b5e..52d44295e 100644 --- a/backend/api/bootstrap.ts +++ b/backend/api/bootstrap.ts @@ -4,14 +4,15 @@ import type { FastifyInstance } from 'fastify' /** * Bootstrap API Route * - * The three things the app has to know before it can draw anything: which site it is on, which system - * flags are set, and who is asking. Each has an endpoint of its own — the admin area reads the flags, - * the login flow asks who is logged in once that has changed — but a full load needs all three at - * once, and asking for them one at a time is three round trips before the first pixel. + * The things the app has to know before it can draw anything: which site it is on, which system flags + * are set, how the instance authenticates, and who is asking. Each has an endpoint of its own — the + * admin area reads the flags, the login flow asks who is logged in once that has changed — but a full + * load needs all of them at once, and asking one at a time is that many round trips before the first + * pixel. * - * None of them touches the database: the site configurations, the flags and the locale list are in - * memory, and the session carries the user. So what this saves is the round trips, which is the whole - * cost. + * None of them touches the database: the site configurations, the flags, the authentication settings + * and the locale list are in memory, and the session carries the user. So what this saves is the + * round trips, which is the whole cost. */ async function routes(app: FastifyInstance) { app.get<{ Querystring: { hostname?: string } }>( @@ -23,7 +24,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Everything the app needs to start', description: - 'The site for the hostname, the system flags, and the current session — the same answers `sites/{hostname}`, `system/flags` and `users/whoami` give, in one request.\n\nCarries the session, so it is never cached.', + 'The site for the hostname, the system flags, the instance-wide authentication settings and the current session — the same answers `sites/{hostname}`, `system/flags`, `authentication/config` and `users/whoami` give, in one request.\n\nThe authentication settings are here rather than read from their own endpoint because that one is behind `manage:system`, while what they decide — whether a passkey may be signed in with, whether a profile may be edited — has to be known to whoever is looking, logged in or not.\n\nCarries the session, so it is never cached.', tags: ['System'], querystring: { type: 'object', @@ -37,11 +38,12 @@ async function routes(app: FastifyInstance) { }, response: { 200: { - description: 'Site, flags and session', + description: 'Site, flags, authentication settings and session', type: 'object', properties: { site: { $ref: 'Site#' }, flags: { $ref: 'SystemFlags#' }, + auth: { $ref: 'AuthConfig#' }, user: { type: 'object', description: @@ -76,6 +78,7 @@ async function routes(app: FastifyInstance) { isEnabled: site.isEnabled }, flags: WIKI.models.flags.getFlags(), + auth: WIKI.models.authentication.getConfig(), user: whoAmI(req), locales: await WIKI.models.locales.getInstalledLocales() } diff --git a/backend/api/schemas/authentication.ts b/backend/api/schemas/authentication.ts index 40e6b5ae9..604daec19 100644 --- a/backend/api/schemas/authentication.ts +++ b/backend/api/schemas/authentication.ts @@ -93,6 +93,27 @@ export async function registerSchemas(app: FastifyInstance): Promise { } }) + /** + * AUTH CONFIG - The instance-wide authentication settings. Used both ways: as the response, and + * as a partial update body + */ + app.addSchema({ + $id: 'AuthConfig', + type: 'object', + properties: { + allowPasskeys: { + type: 'boolean', + description: + 'Whether a passkey may be registered or signed in with. Turned off, the passkeys already registered are kept and start working again the moment it is turned back on.' + }, + allowProfileEditing: { + type: 'boolean', + description: + 'Whether a user may edit their own profile. Off for an instance whose user records are owned by an identity provider.' + } + } + }) + /** * AUTH STRATEGY - A configured instance of a module */ diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index 1c1f8f4a4..ecc9d63c5 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -96,9 +96,6 @@ export async function registerSchemas(app: FastifyInstance): Promise { comments: { type: 'boolean' }, - profile: { - type: 'boolean' - }, reasonForChange: { type: 'string', enum: ['off', 'optional', 'required'] diff --git a/backend/api/users.ts b/backend/api/users.ts index d5da69adc..b84e1e6a4 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -76,18 +76,26 @@ async function systemUserGuard(req: FastifyRequest, userId: string): Promise { - const site = req.hostname - ? await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }) - : null - return !site || site.config?.features?.profile !== false -} +const PERSONAL_PROFILE_FIELDS = [ + 'timezone', + 'dateFormat', + 'timeFormat', + 'appearance', + 'cvd' +] as const /** * Users API Routes @@ -273,7 +281,7 @@ async function routes(app: FastifyInstance) { schema: { summary: "Update the logged in user's own profile", description: - 'Updates any subset of the profile fields; omitted ones are left unchanged. Requires the current site to have the `profile` feature enabled. The email cannot be changed here, and neither can any field an administrator owns.', + 'Updates any subset of the profile fields; omitted ones are left unchanged. The name, location, job title and pronouns require profile editing to be enabled on this wiki (Administration → Authentication) and are refused otherwise; the time zone, date and time formats, appearance and colour-vision settings are the user’s own and are always accepted. The email cannot be changed here, and neither can any field an administrator owns.', tags: ['Users'], body: { $ref: 'UserProfileUpdate#' @@ -302,10 +310,6 @@ async function routes(app: FastifyInstance) { if (!userId) { return reply.unauthorized() } - if (!(await isProfileEditable(req))) { - return reply.forbidden('Profile editing is disabled on this site.') - } - // -> A bad time zone would break every date the user sees, and the list of valid zones is only // known at runtime, so it cannot be expressed as a schema enum if (req.body.timezone !== undefined && req.body.timezone !== '') { @@ -318,21 +322,19 @@ async function routes(app: FastifyInstance) { } const patch: UserProfilePatch = {} - for (const key of [ - 'name', - 'location', - 'jobTitle', - 'pronouns', - 'timezone', - 'dateFormat', - 'timeFormat', - 'appearance', - 'cvd' - ] as const) { + for (const key of [...IDENTITY_PROFILE_FIELDS, ...PERSONAL_PROFILE_FIELDS] as const) { if (req.body[key] !== undefined) { patch[key] = req.body[key] } } + if (!WIKI.models.authentication.isProfileEditingAllowed()) { + const refused = IDENTITY_PROFILE_FIELDS.filter((key) => patch[key] !== undefined) + if (refused.length > 0) { + return reply.forbidden( + `Profile editing is disabled on this wiki: ${refused.join(', ')} cannot be changed here.` + ) + } + } if (Object.keys(patch).length < 1) { throw new CustomError('userProfileEmpty', 'No profile fields provided to update.') } @@ -379,7 +381,7 @@ async function routes(app: FastifyInstance) { { schema: { summary: "Replace the logged in user's own avatar", - description: `The body is the raw image, not a multipart form — send the file itself with its \`Content-Type\`. At most ${avatarUploadLimit / 1024 / 1024} MB, and it must really be one of the accepted formats: the bytes are checked, not the declared type. Resized to a 180x180 JPEG when the Sharp extension is installed, otherwise stored as uploaded. Requires the current site to have the \`profile\` feature enabled.`, + description: `The body is the raw image, not a multipart form — send the file itself with its \`Content-Type\`. At most ${avatarUploadLimit / 1024 / 1024} MB, and it must really be one of the accepted formats: the bytes are checked, not the declared type. Resized to a 180x180 JPEG when the Sharp extension is installed, otherwise stored as uploaded. Requires profile editing to be enabled on this wiki (Administration → Authentication).`, tags: ['Users'], consumes: [...imageMimeTypes], response: { @@ -403,8 +405,8 @@ async function routes(app: FastifyInstance) { if (!userId) { return reply.unauthorized() } - if (!(await isProfileEditable(req))) { - return reply.forbidden('Profile editing is disabled on this site.') + if (!WIKI.models.authentication.isProfileEditingAllowed()) { + return reply.forbidden('Profile editing is disabled on this wiki.') } const data = req.body @@ -442,7 +444,7 @@ async function routes(app: FastifyInstance) { schema: { summary: "Remove the logged in user's own avatar", description: - 'Leaves the user to be rendered as a placeholder again. Succeeds even if there was no avatar to remove. Requires the current site to have the `profile` feature enabled.', + 'Leaves the user to be rendered as a placeholder again. Succeeds even if there was no avatar to remove. Requires profile editing to be enabled on this wiki (Administration → Authentication).', tags: ['Users'], response: { 200: { @@ -465,8 +467,8 @@ async function routes(app: FastifyInstance) { if (!userId) { return reply.unauthorized() } - if (!(await isProfileEditable(req))) { - return reply.forbidden('Profile editing is disabled on this site.') + if (!WIKI.models.authentication.isProfileEditingAllowed()) { + return reply.forbidden('Profile editing is disabled on this wiki.') } await WIKI.models.users.clearAvatar(userId) @@ -663,6 +665,11 @@ async function routes(app: FastifyInstance) { type: 'boolean', description: 'Whether the account has another way in — a passkey or another linked provider — and may therefore turn password login off.' + }, + canChangePassword: { + type: 'boolean', + description: + 'Whether this strategy lets a user change their own password here. False where an administrator has turned `allowPasswordChange` off, which does not stop a password change the wiki itself demands at sign-in.' } } } @@ -700,7 +707,7 @@ async function routes(app: FastifyInstance) { schema: { summary: "Change the logged in user's own password", description: - 'The current password has to be given, and is what authorizes the change. Only a provider that stores the password on this instance can be changed here. Also clears any pending forced password change.', + 'The current password has to be given, and is what authorizes the change. Only a provider that stores the password on this instance can be changed here, and only while its `allowPasswordChange` setting is on. Also clears any pending forced password change.', tags: ['Users'], body: { type: 'object', @@ -733,6 +740,17 @@ async function routes(app: FastifyInstance) { return reply.unauthorized() } + /* + The strategy's own setting, not an instance-wide one: a wiki whose passwords are handed out + by an administrator turns it off, and the strategy is where that is configured. It governs + this route alone — a password change the wiki DEMANDS at sign-in (`mustChangePwd`) runs + through the login continuation and is not somebody choosing to change their password. + */ + const strategy = await WIKI.models.authentication.getStrategyById(req.body.strategyId) + if (strategy?.config?.allowPasswordChange === false) { + return reply.forbidden('This authentication strategy does not allow password changes.') + } + try { await WIKI.models.users.changeOwnPassword({ userId, @@ -1029,6 +1047,10 @@ async function routes(app: FastifyInstance) { return reply.unauthorized() } + if (!WIKI.models.authentication.arePasskeysAllowed()) { + return reply.forbidden('Passkeys are turned off on this wiki.') + } + try { const { registrationOptions, pending } = await WIKI.models.passkeys.startRegistration({ userId, @@ -1092,6 +1114,10 @@ async function routes(app: FastifyInstance) { return reply.unauthorized() } + if (!WIKI.models.authentication.arePasskeysAllowed()) { + return reply.forbidden('Passkeys are turned off on this wiki.') + } + try { const passkey = await WIKI.models.passkeys.finalizeRegistration({ userId, diff --git a/backend/base.yml b/backend/base.yml index 30afa8982..3940c120c 100644 --- a/backend/base.yml +++ b/backend/base.yml @@ -106,6 +106,12 @@ defaults: hideLocal: false loginBgUrl: '' secret: 'abcdef1234567890abcdef1234567890abcdef' + # Whether passkeys may be used at all. Off, a passkey can neither be registered nor signed in + # with — the ones already registered are kept, and work again the moment it is back on. + allowPasskeys: true + # Whether a user may edit their own profile — their name, their location, their avatar. Off + # for an instance whose user records come from an identity provider and are written there. + allowProfileEditing: true security: corsMode: 'OFF' corsConfig: '' diff --git a/backend/locales/en.json b/backend/locales/en.json index 2d491dcd7..1f93fdebe 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -193,6 +193,7 @@ "admin.audit.actions.updateApprovalRule": "Updated an approval rule", "admin.audit.actions.updateAsset": "Renamed or moved a file", "admin.audit.actions.updateAuditConfig": "Changed the audit log retention", + "admin.audit.actions.updateAuthConfig": "Changed the authentication configuration", "admin.audit.actions.updateAuthStrategy": "Updated an authentication strategy", "admin.audit.actions.updateAvatar": "Changed their avatar", "admin.audit.actions.updateBlock": "Changed the blocks of a site", @@ -265,17 +266,23 @@ "admin.auth.activeStrategies": "Active Strategies", "admin.auth.addPending": "{strategy} added. It is created when you press Apply.", "admin.auth.addStrategy": "Add Strategy", + "admin.auth.allowPasskeys": "Allow Passkeys", + "admin.auth.allowPasskeysHint": "Can users sign in with a passkey, and register new ones? Passkeys that are already registered are kept while this is off, and work again as soon as you turn it back on.", + "admin.auth.allowProfileEditing": "Allow Profile Editing", + "admin.auth.allowProfileEditingHint": "Can users edit their own profile? If profile data is managed by an external identity provider, you should turn this off.", "admin.auth.allowedEmailRegex": "Allowed Email Address Regex", "admin.auth.allowedEmailRegexHint": "(optional) Only allow users to register with an email address that matches the regex expression.", "admin.auth.allowedWebOrigins": "Allowed Web Origins", "admin.auth.autoEnrollGroups": "Assign to group(s)", "admin.auth.autoEnrollGroupsHint": "(optional) Automatically assign new users to these groups. New users are always added to the Users group regardless of this setting.", "admin.auth.callbackUrl": "Callback URL / Redirect URI", + "admin.auth.config": "Configuration", + "admin.auth.configHint": "Settings that apply to every site of this instance.", "admin.auth.configReference": "Configuration Reference", "admin.auth.configReferenceSubtitle": "Some strategies may require some configuration values to be set on your provider. These are provided for reference only and may not be needed by the current strategy.", + "admin.auth.configSaveFailed": "Failed to save the authentication configuration.", "admin.auth.deleteConfirm": "Are you sure you want to delete the {strategy} strategy? Users who can only sign in through it will lose access.", "admin.auth.deleteFailed": "Failed to delete the strategy.", - "admin.auth.deleteLocalForbidden": "Every account is registered against the local strategy, so it cannot be deleted.", "admin.auth.deleteStrategy": "Delete Strategy", "admin.auth.deleteSuccess": "{strategy} has been deleted.", "admin.auth.displayName": "Display Name", @@ -433,8 +440,6 @@ "admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Applies to the markdown editor. Changes are still only stored when someone saves the page.", "admin.general.allowComments": "Allow Comments", "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.", - "admin.general.allowProfile": "Allow Profile Editing", - "admin.general.allowProfileHint": "Can users edit their own profile? If profile data is managed by an external identity provider, you should turn this off.", "admin.general.allowRatings": "Allow Ratings", "admin.general.allowRatingsHint": "Can users leave ratings on pages? Can be restricted using Page Rules.", "admin.general.allowSearch": "Allow Search", @@ -2477,6 +2482,7 @@ "profile.authInfo": "Your account is associated with the following authentication methods:", "profile.authLoadingFailed": "Failed to load authentication methods.", "profile.authModifyTfa": "Modify 2FA", + "profile.authPasswordChangeDisabled": "Your wiki administrator has disabled password changes for this login method.", "profile.authPasswordLoginOff": "Password login is turned off for this account.", "profile.authPasswordLoginOnlyMethod": "Register a passkey or link another authentication method before turning this off.", "profile.authSetTfa": "Set 2FA", @@ -2504,7 +2510,7 @@ "profile.dateFormatHint": "Set your preferred format to display dates.", "profile.displayName": "Display Name", "profile.displayNameHint": "Your full name; shown when authoring content (e.g. pages, comments, etc.).", - "profile.editDisabledDescription": "Your wiki administrator has disabled profile editing.", + "profile.editDisabledDescription": "Your wiki administrator has disabled profile editing. Your preferences and accessibility settings below can still be changed.", "profile.editDisabledTitle": "Profile info is managed by your organization.", "profile.email": "Email Address", "profile.emailHint": "The email address used for login.", @@ -2533,6 +2539,7 @@ "profile.passkeysDeactivateConfirm": "Are you sure you want to deactivate this passkey?", "profile.passkeysDeactivateFailed": "Failed to deactivate the passkey.", "profile.passkeysDeactivateSuccess": "Passkey deactivated successfully. You may still need to remove the passkey from your device.", + "profile.passkeysDisabled": "Passkeys are turned off on this wiki. Any you have registered are kept, and work again if an administrator turns them back on.", "profile.passkeysIntro": "Passkeys are a replacement for passwords for a faster, easier and more secure login. It relies on your device existing biometrics (phone, computer, security key) to validate your identity.", "profile.passkeysInvalidName": "Passkey name is missing or invalid.", "profile.passkeysName": "Passkey Name", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 342b903cd..8064a93bc 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -72,6 +72,7 @@ export const AUDIT_ACTIONS = { 'createApprovalRule', 'updateApprovalRule', 'deleteApprovalRule', + 'updateAuthConfig', 'createAuthStrategy', 'updateAuthStrategy', 'deleteAuthStrategy', diff --git a/backend/models/authentication.ts b/backend/models/authentication.ts index 054ea7e06..9d24e14bf 100644 --- a/backend/models/authentication.ts +++ b/backend/models/authentication.ts @@ -132,10 +132,82 @@ function isBuiltInLocal(id: string): boolean { return id === WIKI.data.systemIds.localAuthId } +/** + * The instance-wide authentication settings, i.e. the half of the `auth` settings blob a person + * sets. The rest of that blob — the session secret, the signing keypair, the seeded IDs — is the + * installation's own and is never read or written through here. + */ +export const AUTH_CONFIG_FIELDS = ['allowPasskeys', 'allowProfileEditing'] as const + /** * Authentication model */ class Authentication { + /** + * The instance-wide authentication settings, as the admin area expects them + */ + getConfig(): Record { + const auth = WIKI.config.auth ?? {} + const config: Record = {} + for (const field of AUTH_CONFIG_FIELDS) { + config[field] = auth[field] !== false + } + return config + } + + /** + * Keep only the fields this model owns, dropping anything else a client sends. + * + * Which is what keeps the secrets in the same blob out of reach: `certs` and `secret` are not + * fields of this configuration, so no route that goes through here can be talked into writing one. + */ + pickFields(body: Record): Record { + const patch: Record = {} + for (const field of AUTH_CONFIG_FIELDS) { + if (body[field] !== undefined) { + patch[field] = Boolean(body[field]) + } + } + return patch + } + + /** + * Save a patch of the instance-wide settings. + * + * @returns Whether the settings were saved + */ + async updateConfig(patch: Record): Promise { + const previousAuth = WIKI.config.auth + WIKI.config.auth = { ...previousAuth, ...patch } + + if (!(await WIKI.configSvc.saveToDb(['auth']))) { + WIKI.config.auth = previousAuth + return false + } + return true + } + + /** + * Whether a passkey may be registered or signed in with. + * + * Turned off, the passkeys already registered are left where they are rather than deleted — this + * is a setting an administrator can turn back on, and a wiki that forgot every passkey in the + * meantime would have made that a one-way door. + */ + arePasskeysAllowed(): boolean { + return WIKI.config.auth?.allowPasskeys !== false + } + + /** + * Whether a user may edit their own profile. + * + * Instance-wide rather than per site: the profile is one record on one account, and an account + * that reaches two sites of an instance cannot have it editable on one of them and not the other. + */ + isProfileEditingAllowed(): boolean { + return WIKI.config.auth?.allowProfileEditing !== false + } + async getStrategy(module: string) { return WIKI.db.select().from(authenticationTable).where(eq(authenticationTable.module, module)) } diff --git a/backend/models/settings.ts b/backend/models/settings.ts index b632ab746..8983b9b3e 100644 --- a/backend/models/settings.ts +++ b/backend/models/settings.ts @@ -70,7 +70,9 @@ class Settings { secret: crypto.randomBytes(32).toString('hex'), rootAdminGroupId: ids.groupAdminId, rootAdminUserId: ids.userAdminId, - guestUserId: ids.userGuestId + guestUserId: ids.userGuestId, + allowPasskeys: true, + allowProfileEditing: true } }, { diff --git a/backend/models/sites.ts b/backend/models/sites.ts index 3f8225c96..3b590a223 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -118,7 +118,6 @@ class Sites { ratings: false, ratingsMode: 'off', comments: false, - profile: true, reasonForChange: 'optional', search: true }, @@ -384,7 +383,6 @@ class Sites { ratings: false, ratingsMode: 'off', comments: false, - profile: true, reasonForChange: 'optional', search: true }, diff --git a/backend/models/users.ts b/backend/models/users.ts index beb37e503..9df2aaf9c 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -78,6 +78,8 @@ export interface UserProfileAuthMethod { isPasswordLoginEnabled: boolean /** Whether the account has another way in, and may therefore turn password login off. */ canDisablePasswordLogin: boolean + /** Whether the strategy lets a user change this password from their own profile. */ + canChangePassword: boolean } } @@ -216,16 +218,19 @@ const maxTfaAttempts = 5 * How many ways into the account remain if the given provider stops working: the other providers * linked to it, plus every registered passkey. * - * A provider that is itself restricted does not count — it is no way in either. Passkeys are counted - * whichever host they were registered against: on a multi-site instance one bound to another site - * still leaves the account reachable, which is what this guards against. + * A provider that is itself restricted does not count — it is no way in either. Neither is a passkey + * on an instance where passkeys are turned off, however many the account has registered. The ones + * that do count are counted whichever host they were registered against: on a multi-site instance one + * bound to another site still leaves the account reachable, which is what this guards against. */ function countAlternativeLogins(user: any, strategyId: string): number { const auth = (user.auth ?? {}) as Record const otherProviders = Object.entries(auth).filter( ([id, config]) => id !== strategyId && !config?.restrictLogin ).length - const passkeys = ((user.passkeys ?? {}).authenticators ?? []).length + const passkeys = WIKI.models.authentication.arePasskeysAllowed() + ? ((user.passkeys ?? {}).authenticators ?? []).length + : 0 return otherProviders + passkeys } @@ -1028,7 +1033,12 @@ class Users { return [] } - const strategies = await WIKI.db.select().from(authenticationTable) + /* + The completed strategies rather than the raw rows: a prop declared by a module after a strategy + was configured is missing from what is stored, and `allowPasswordChange` — like `enforceTfa` + below — has to read as the module's default there rather than as absent. + */ + const strategies = await WIKI.models.authentication.getActiveStrategies() const methods: UserProfileAuthMethod[] = [] for (const [strategyId, rawConfig] of Object.entries( (user.auth ?? {}) as Record @@ -1048,7 +1058,11 @@ class Users { config.tfaRequired || (strategy?.config as Record)?.enforceTfa ), isPasswordLoginEnabled: !config.restrictLogin, - canDisablePasswordLogin: countAlternativeLogins(user, strategyId) > 0 + canDisablePasswordLogin: countAlternativeLogins(user, strategyId) > 0, + // -> `!== false` rather than `=== true`, so a module that declares no such prop at all is + // not read as forbidding something it has no opinion about + canChangePassword: + (strategy?.config as Record)?.allowPasswordChange !== false } }) } diff --git a/backend/modules/authentication/local/definition.yml b/backend/modules/authentication/local/definition.yml index 6d41f8976..b28cf0c75 100644 --- a/backend/modules/authentication/local/definition.yml +++ b/backend/modules/authentication/local/definition.yml @@ -21,6 +21,12 @@ props: hint: Send a verification email with a validation link when somebody registers, and refuse them a login until they follow it. Requires a configured mail server — registration is refused outright without one. icon: received default: true + allowPasswordChange: + type: Boolean + title: Allow Password Change + hint: Users can change their own password from their profile page. Turn this off where passwords are set by an administrator, or where the account's password is managed somewhere else. + icon: password + default: true allowForgotPassword: type: Boolean title: Allow Forgot Password diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 84fadb4eb..0f9c59b97 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -21,6 +21,7 @@ import WDialogHost from '@/components/shared/WDialogHost.vue' import WLoadingOverlay from '@/components/shared/WLoadingOverlay.vue' import WNotifications from '@/components/shared/WNotifications.vue' +import { useAuthConfigStore } from '@/stores/authConfig' import { useCommonStore } from './stores/common' import { useFlagsStore } from '@/stores/flags' import { useSiteStore } from '@/stores/site' @@ -48,6 +49,7 @@ const dark = useDark() // STORES +const authConfigStore = useAuthConfigStore() const commonStore = useCommonStore() const flagsStore = useFlagsStore() const siteStore = useSiteStore() @@ -213,6 +215,7 @@ async function loadBootstrap() { siteStore.installedLocales = data.locales ?? [] siteStore.applySiteInfo(data.site) flagsStore.apply(data.flags) + authConfigStore.apply(data.auth) userStore.applyProfile(data.user) } catch (err) { console.warn(`Could not load the site configuration: ${err.message}`) diff --git a/frontend/src/components/AuthLoginPanel.vue b/frontend/src/components/AuthLoginPanel.vue index c9237f6dc..053f3cc69 100644 --- a/frontend/src/components/AuthLoginPanel.vue +++ b/frontend/src/components/AuthLoginPanel.vue @@ -550,6 +550,7 @@ import { apiErrorMessage } from '@/helpers/apiError' import { copyToClipboard } from '@/helpers/clipboard' import { localizeError } from '@/helpers/localization' +import { useAuthConfigStore } from '@/stores/authConfig' import { useSiteStore } from '@/stores/site' import { useUserStore } from '@/stores/user' @@ -564,6 +565,7 @@ const dark = useDark() // STORES +const authConfigStore = useAuthConfigStore() const siteStore = useSiteStore() const userStore = useUserStore() @@ -688,8 +690,13 @@ const passwordStrength = computed(() => { } }) +/* + Both halves have to hold: an instance can turn passkeys off, and a browser that cannot do WebAuthn + cannot answer a challenge. The button is absent rather than disabled either way -- there is nothing + a person could do about either one from this screen. +*/ const canUsePasskeys = computed(() => { - return browserSupportsWebAuthn() + return authConfigStore.allowPasskeys && browserSupportsWebAuthn() }) /** The 2FA secret in groups of four, which is how a 32-character string stays readable to type. */ diff --git a/frontend/src/components/shared/WInput.vue b/frontend/src/components/shared/WInput.vue index 8a8f45f28..d63853198 100644 --- a/frontend/src/components/shared/WInput.vue +++ b/frontend/src/components/shared/WInput.vue @@ -467,6 +467,8 @@ const controlClasses = computed(() => [ ? 'bg-white dark:bg-black/20' : 'rounded-b-none bg-black/4 dark:bg-white/6', props.disable || props.disabled ? 'pointer-events-none opacity-60' : '', + // -> Says the field is not the reader's to change; see the stripe rule in `tailwind.css` + props.readonly || props.disable || props.disabled ? 'w-input-control--locked' : '', /* `relative` for the outline and the label. The margin is the room the floated label needs above the control, and it is matched below so the field's box stays symmetric about the control -- otherwise diff --git a/frontend/src/components/shared/WSelect.vue b/frontend/src/components/shared/WSelect.vue index 8572a5961..6e3761ff0 100644 --- a/frontend/src/components/shared/WSelect.vue +++ b/frontend/src/components/shared/WSelect.vue @@ -767,6 +767,8 @@ const controlClasses = computed(() => [ isDisabled.value ? 'pointer-events-none opacity-60' : '', // -> readonly keeps full contrast; only the pointer affordance goes away props.readonly ? 'cursor-default' : isDisabled.value ? '' : 'cursor-pointer', + // -> Says the field is not the reader's to change; see the stripe rule in `tailwind.css` + props.readonly || isDisabled.value ? 'w-input-control--locked' : '', // -> Room for the floated label above, matched below so the control stays centred in its row; see // the fuller note in WInput hasFloatingLabel.value ? (showsBottom.value ? 'relative mt-2' : 'relative my-2') : '' diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss index ef669ed05..dc7ba9ab2 100644 --- a/frontend/src/css/_page-contents.scss +++ b/frontend/src/css/_page-contents.scss @@ -1756,10 +1756,24 @@ so growing the host box would leave a 1em glyph centred in a 1.4em one. Scaling what that `em` measures against grows the drawing. An explicit `width` on the element is in pixels and is untouched by this, exactly as the inline style above leaves the inlined form alone. + + Aligned here rather than left alone, which is the other difference: Preflight gives every `svg` + `vertical-align: middle`, so the inlined form sits centred on the line without asking, while the + element is a custom tag Preflight never matches and its own `:host` rule sits it on the baseline + -- a 1.4em box on the baseline rides up over the text it was written into. An outer-tree + declaration beats a shadow tree's `:host` whatever the specificity, so this is all it takes, and + saying it here is what makes the editor's preview and the saved page the same picture. + + `inline` is the author asking for the baseline instead, and it still means that: the attribute + nudges the element to the -0.125em Iconify aligns text-height icons by, which is exactly what + `inlineIcons` writes onto the saved `` for the same attribute. */ iconify-icon { font-size: 1.4em; } + iconify-icon:not([inline]) { + vertical-align: middle; + } /* Twemoji, which the renderer swaps in for `:shortcodes:` */ img.emoji { diff --git a/frontend/src/css/tailwind.css b/frontend/src/css/tailwind.css index 079c0cf89..d8e11c610 100644 --- a/frontend/src/css/tailwind.css +++ b/frontend/src/css/tailwind.css @@ -465,6 +465,36 @@ --w-input-ring-hover: var(--color-white); } + /* + A field that cannot be typed into, hatched. + + Nothing else said so. A disabled control has `opacity-60`, which reads as "dimmer" rather than + "locked" and says nothing at all about a READONLY one -- and readonly is the case that matters + here, since a profile whose name comes from an identity provider draws its fields at full + contrast with a live-looking cursor. The hatching is the one state a glance can tell apart from + an empty field. + + A background-image over the control's own background-color, so it composes with whichever fill + the variant chose (white, the dark well, `bg-black/4`) instead of replacing it. Hard stops rather + than a soft gradient: the stripe is barely there as it is, and an interpolated edge would blur it + into a flat tint at this alpha. Band and gap are the same 4px, so it reads as one alternating + pattern rather than as lines ruled across a field -- an 8px period, fine enough to show on a 36px + dense field and coarse enough not to moire on a full-height one. + */ + .w-input-control--locked { + --w-input-stripe: rgb(0 0 0 / 0.03); + background-image: repeating-linear-gradient( + -45deg, + var(--w-input-stripe) 0 4px, + transparent 4px 8px + ); + } + + /* -> Higher alpha than the light-mode value, since white over a dark fill separates less */ + body.body--dark .w-input-control--locked { + --w-input-stripe: rgb(255 255 255 / 0.045); + } + /* The picker button on a date or time field, in dark mode. diff --git a/frontend/src/pages/AdminAuth.vue b/frontend/src/pages/AdminAuth.vue index 946d5b490..6c829f26a 100644 --- a/frontend/src/pages/AdminAuth.vue +++ b/frontend/src/pages/AdminAuth.vue @@ -10,7 +10,21 @@ {{ t('admin.auth.subtitle') }} -
+
+ +
+ + + -
+
@@ -119,281 +136,344 @@
- -
- - {{ t('admin.auth.info') }} - - - - {{ t(`admin.auth.infoName`) }} - {{ t(`admin.auth.infoNameHint`) }} - - - - - - - - - - {{ t(`admin.auth.enabled`) }} - {{ t(`admin.auth.enabledHint`) }} - {{ - t(`admin.auth.enabledForced`) - }} - {{ - t(`admin.auth.enabledSiteHint`) - }} - - - - - - - - - - {{ t(`admin.auth.registration`) }} - {{ - state.strategy.strategy.key === `local` - ? t(`admin.auth.registrationLocalHint`) - : t(`admin.auth.registrationHint`) - }} - - - - - - - - - - - - {{ t('admin.auth.strategyConfiguration') }} - - - {{ t('admin.auth.noConfigOption') }} - - - - - - - - + +
+
+ + + + + + + +
{{ state.strategy.strategy.title }}
+
{{ state.strategy.strategy.description }}
+
+
+ + + +
+ ID: {{ state.strategy.id }} +
+
+
+
+
+ + + +
+
+ - {{ t('admin.auth.configReference') }} - + {{ t('admin.auth.config') }} + - - + + - {{ strRef.title }} - {{ strRef.hint }} + {{ t(`admin.auth.allowPasskeys`) }} + {{ t(`admin.auth.allowPasskeysHint`) }} + + + + + + + - - - {{ t('admin.auth.refAfterSave') }} - - + {{ t(`admin.auth.allowProfileEditing`) }} + {{ t(`admin.auth.allowProfileEditingHint`) }} + + + - - - - - - - -
{{ state.strategy.strategy.title }}
-
{{ state.strategy.strategy.description }}
-
-
-
-
ID: {{ state.strategy.id }}
- - - {{ t('admin.auth.deleteLocalForbidden') }} - -
@@ -410,6 +490,7 @@ import { notify } from '@/composables/notify' import { loading } from '@/composables/loading' import { confirm } from '@/composables/dialog' +import { useAuthConfigStore } from '@/stores/authConfig' import { useSiteStore } from '@/stores/site' import { apiErrorMessage } from '@/helpers/apiError' @@ -419,6 +500,7 @@ const dark = useDark() // STORES +const authConfigStore = useAuthConfigStore() const siteStore = useSiteStore() // I18N @@ -443,12 +525,18 @@ const BUILTIN_LOCAL_STRATEGY_ID = '5a528c4c-0a82-4ad2-96a5-2b23811e6588' const state = reactive({ loading: 0, loadingGroups: true, + displayMode: 'strategies', groups: [], strategies: [], activeStrategies: [], selectedStrategy: '', strategy: { strategy: {} + }, + /** The instance-wide settings, which belong to no strategy — the Configuration screen. */ + config: { + allowPasskeys: true, + allowProfileEditing: true } }) @@ -565,12 +653,21 @@ async function load() { state.loadingGroups = true loading.show() try { - const [modules, strategies, groups] = await Promise.all([ + const [modules, strategies, config, groups] = await Promise.all([ API_CLIENT.get('authentication/modules').json(), API_CLIENT.get('authentication/strategies').json(), + API_CLIENT.get('authentication/config').json(), API_CLIENT.get('groups').json() ]) state.strategies = modules ?? [] + state.config = { ...state.config, ...config } + /* + The running app holds these from `bootstrap`, which answered before this screen touched them — + so without this, an administrator who turns passkeys off goes on being offered a passkey on the + login screen (and their own profile goes on offering to register one) until the next full load. + Here rather than in `save`, so opening the screen also settles a change made from elsewhere. + */ + authConfigStore.apply(state.config) state.activeStrategies = (strategies ?? []).map((str) => { const mod = state.strategies.find((m) => m.key === str.module) ?? { key: str.module, @@ -666,7 +763,27 @@ async function save() { } } - if (failures.length > 0) { + /* + The instance-wide settings go with them, whichever screen is in front: both are edited in this + one page and Apply is the page's own button, so switching tab to check something must not be + what loses the edit made on the other. + */ + let configFailure = null + try { + const resp = await API_CLIENT.put('authentication/config', { + json: { + allowPasskeys: state.config.allowPasskeys, + allowProfileEditing: state.config.allowProfileEditing + } + }).json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } + } catch (err) { + configFailure = apiErrorMessage(err) + } + + if (failures.length > 0 || configFailure) { for (const failure of failures) { notify({ type: 'negative', @@ -674,6 +791,13 @@ async function save() { caption: failure.message }) } + if (configFailure) { + notify({ + type: 'negative', + message: t('admin.auth.configSaveFailed'), + caption: configFailure + }) + } } else { notify({ type: 'positive', diff --git a/frontend/src/pages/AdminGeneral.vue b/frontend/src/pages/AdminGeneral.vue index 36130362d..ab8ed5d9c 100644 --- a/frontend/src/pages/AdminGeneral.vue +++ b/frontend/src/pages/AdminGeneral.vue @@ -202,19 +202,6 @@ - - - - {{ t(`admin.general.allowProfile`) }} - {{ t(`admin.general.allowProfileHint`) }} - - - - - -