From 7f2207bc28e45c7971c9a4941c494d3a69e1a89f Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Fri, 12 Jun 2026 08:57:17 -0400 Subject: [PATCH] feat(settings): password change, 2FA enable/disable, API-key UI + Swagger; fix Owner RBAC drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings was missing self-service account security and any API-key UI: - Account security (new Security tab): change password (POST /auth/change-password — verifies current via Argon2, rejects unchanged), enable 2FA (wires the existing /auth/2fa/setup QR + /auth/2fa/verify), and disable 2FA (new POST /auth/2fa/disable, requires a current code so a hijacked session can't strip the second factor). - New API tab: create/list/revoke per-license API keys (the overnight backend had no UI), plaintext shown once, plus an 'API docs' button to /api/docs (Swagger). Root-cause RBAC fix — the system-default Owner role enumerated per-resource wildcards (server.*, wipe.*, ...) and drifted: apikeys, webhooks, alerts, analytics, chat, schedules, notifications, map, users and ALL plugin-config modules (plus singular plugin.* vs granted plugins.*) were locked out for any non-super-admin Owner. Owner = full control of its license: - migration 025 sets the Owner role to {"*": true} - PermissionsGuard honors '*' as allow-all - frontend hasPermission honors '*' and resource.* wildcards (was exact-match only, so wildcard-based roles silently failed) Backend tsc + frontend build green. NOTE: migration 025 auto-applies on a fresh DB (Saturday); the live DB needs the one-line UPDATE applied to unlock the API tab for a non-super-admin owner. Co-Authored-By: Claude Opus 4.8 --- .../src/common/guards/permissions.guard.ts | 4 + .../src/modules/auth/auth.controller.ts | 25 ++ backend-nest/src/modules/auth/auth.service.ts | 50 +++ .../modules/auth/dto/change-password.dto.ts | 14 + backend/migrations/025_owner_full_access.sql | 15 + frontend/src/stores/auth.ts | 7 +- frontend/src/views/admin/SettingsView.vue | 303 ++++++++++++++++++ 7 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 backend-nest/src/modules/auth/dto/change-password.dto.ts create mode 100644 backend/migrations/025_owner_full_access.sql diff --git a/backend-nest/src/common/guards/permissions.guard.ts b/backend-nest/src/common/guards/permissions.guard.ts index e417c9c..97d4456 100644 --- a/backend-nest/src/common/guards/permissions.guard.ts +++ b/backend-nest/src/common/guards/permissions.guard.ts @@ -28,6 +28,10 @@ export class PermissionsGuard implements CanActivate { const permissions = user.permissions as Record | undefined; if (!permissions) return false; + // Global wildcard — the Owner role (full control of its license) carries + // {"*": true}, so new features never need to amend the role enumeration. + if (permissions['*'] === true) return true; + // Support wildcard: "server.*" matches "server.view", "server.console", etc. const parts = requiredPermission.split('.'); const wildcard = parts[0] + '.*'; diff --git a/backend-nest/src/modules/auth/auth.controller.ts b/backend-nest/src/modules/auth/auth.controller.ts index f276358..56c0a39 100644 --- a/backend-nest/src/modules/auth/auth.controller.ts +++ b/backend-nest/src/modules/auth/auth.controller.ts @@ -13,6 +13,7 @@ import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { VerifyTotpDto } from './dto/verify-totp.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ChangePasswordDto } from './dto/change-password.dto'; import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { ResetPasswordDto } from './dto/reset-password.dto'; import { Public } from '../../common/decorators/public.decorator'; @@ -61,6 +62,30 @@ export class AuthController { return this.authService.verifyTotp(userId, dto.code); } + @Post('2fa/disable') + @ApiBearerAuth() + @ApiOperation({ summary: 'Disable TOTP 2FA (requires a current code)' }) + async disableTotp( + @CurrentUser('sub') userId: string, + @Body() dto: VerifyTotpDto, + ) { + return this.authService.disableTotp(userId, dto.code); + } + + @Post('change-password') + @ApiBearerAuth() + @ApiOperation({ summary: 'Change the current user password' }) + async changePassword( + @CurrentUser('sub') userId: string, + @Body() dto: ChangePasswordDto, + ) { + return this.authService.changePassword( + userId, + dto.current_password, + dto.new_password, + ); + } + @Get('me') @ApiBearerAuth() @ApiOperation({ summary: 'Get current user profile' }) diff --git a/backend-nest/src/modules/auth/auth.service.ts b/backend-nest/src/modules/auth/auth.service.ts index 1c96fe2..683db22 100644 --- a/backend-nest/src/modules/auth/auth.service.ts +++ b/backend-nest/src/modules/auth/auth.service.ts @@ -335,6 +335,56 @@ export class AuthService { throw new NotImplementedException('Password reset not yet configured'); } + async changePassword(userId: string, currentPassword: string, newPassword: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + const valid = await argon2.verify(user.password_hash, currentPassword); + if (!valid) { + throw new UnauthorizedException('Current password is incorrect'); + } + + if (await argon2.verify(user.password_hash, newPassword)) { + throw new BadRequestException('New password must be different from the current one'); + } + + const password_hash = await argon2.hash(newPassword); + await this.userRepository.update(user.id, { password_hash }); + this.logger.log(`Password changed for user ${user.id}`); + + // NOTE: existing JWTs remain valid until expiry — this design has no + // server-side refresh-token store to revoke. Session invalidation on + // password change is a follow-up (tracked separately). + return { success: true }; + } + + async disableTotp(userId: string, code: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.totp_enabled) { + throw new BadRequestException('2FA is not enabled'); + } + + // Require a valid current code — proves possession of the second factor + // before removing it, so a hijacked session can't silently strip 2FA. + const valid = await this.verifyTotpCode(user, code); + if (!valid) { + throw new UnauthorizedException('Invalid TOTP code'); + } + + await this.userRepository.update(user.id, { + totp_enabled: false, + totp_secret: null, + }); + this.logger.log(`TOTP disabled for user ${user.id}`); + return { success: true }; + } + // Helper methods private async generateTokens(user: User, licenseId?: string) { diff --git a/backend-nest/src/modules/auth/dto/change-password.dto.ts b/backend-nest/src/modules/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..57cc379 --- /dev/null +++ b/backend-nest/src/modules/auth/dto/change-password.dto.ts @@ -0,0 +1,14 @@ +import { IsString, MinLength, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ChangePasswordDto { + @ApiProperty({ description: 'Current account password' }) + @IsString() + current_password: string; + + @ApiProperty({ description: 'New password', minLength: 8, maxLength: 128 }) + @IsString() + @MinLength(8) + @MaxLength(128) + new_password: string; +} diff --git a/backend/migrations/025_owner_full_access.sql b/backend/migrations/025_owner_full_access.sql new file mode 100644 index 0000000..0c125bf --- /dev/null +++ b/backend/migrations/025_owner_full_access.sql @@ -0,0 +1,15 @@ +-- 025_owner_full_access.sql +-- +-- The system-default Owner role enumerated per-resource wildcards +-- (server.*, wipe.*, players.*, ...). Every feature added since drift past that +-- enumeration: apikeys, webhooks, alerts, analytics, chat, schedules, +-- notifications, map, users, and ALL plugin-config modules (plus a singular +-- 'plugin.*' vs granted 'plugins.*' mismatch) were silently locked out for any +-- non-super-admin Owner — PermissionsGuard denies a permission the role doesn't +-- grant. The Owner has "full control of their license" by definition, so grant +-- a global wildcard instead of an enumeration that must be amended per feature. +-- +-- PermissionsGuard and the frontend auth store both honor "*" as allow-all. +UPDATE roles +SET permissions = '{"*": true}'::jsonb +WHERE role_name = 'Owner' AND is_system_default = true; diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 4f8020f..12a93af 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -97,7 +97,12 @@ export const useAuthStore = defineStore('auth', () => { ? decodeJwtPermissions(accessToken.value) : {} - return perms[permission] === true + // Honor the global wildcard (Owner) and resource wildcards ("server.*") + // so role permissions stored as wildcards aren't missed by an exact match. + if (perms['*'] === true) return true + if (perms[permission] === true) return true + const resourceWildcard = permission.split('.')[0] + '.*' + return perms[resourceWildcard] === true } return { diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index f48cb2f..b5f4f99 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -33,6 +33,31 @@ const publicSiteForm = ref({ status_page_description: '', }) +// --- Security: password change --- +const pwForm = ref({ current_password: '', new_password: '', confirm: '' }) +const changingPw = ref(false) + +// --- Security: 2FA enrollment flow --- +const totp = ref<{ qr: string; secret: string; code: string; setting: boolean }>({ + qr: '', secret: '', code: '', setting: false, +}) +const disable2fa = ref({ open: false, code: '', busy: false }) + +// --- API keys --- +interface ApiKeyRow { + id: string + name: string + key_prefix: string + last_used_at: string | null + is_active: boolean + created_at: string +} +const apiKeys = ref([]) +const newKeyName = ref('') +const creatingKey = ref(false) +const createdKey = ref(null) +const loadingKeys = ref(false) + async function loadForms() { if (auth.user) { accountForm.value.username = auth.user.username @@ -89,16 +114,144 @@ async function savePublicSite() { } } +async function changePassword() { + if (pwForm.value.new_password.length < 8) { + toast.error('New password must be at least 8 characters') + return + } + if (pwForm.value.new_password !== pwForm.value.confirm) { + toast.error('New password and confirmation do not match') + return + } + changingPw.value = true + try { + await api.post('/auth/change-password', { + current_password: pwForm.value.current_password, + new_password: pwForm.value.new_password, + }) + toast.success('Password changed') + pwForm.value = { current_password: '', new_password: '', confirm: '' } + } catch (err) { + toast.error('Failed to change password: ' + (err as Error).message) + } finally { + changingPw.value = false + } +} + +async function startTotpSetup() { + totp.value.setting = true + try { + const res = await api.post<{ qr_code: string; secret: string }>('/auth/2fa/setup', {}) + totp.value.qr = res.qr_code + totp.value.secret = res.secret + } catch (err) { + totp.value.setting = false + toast.error('Failed to start 2FA setup: ' + (err as Error).message) + } +} + +async function confirmTotpSetup() { + if (totp.value.code.length !== 6) { + toast.error('Enter the 6-digit code from your authenticator') + return + } + try { + await api.post('/auth/2fa/verify', { code: totp.value.code }) + await auth.validateSession() + toast.success('Two-factor authentication enabled') + totp.value = { qr: '', secret: '', code: '', setting: false } + } catch (err) { + toast.error('Invalid code — try again: ' + (err as Error).message) + } +} + +function cancelTotpSetup() { + totp.value = { qr: '', secret: '', code: '', setting: false } +} + +async function confirmDisable2fa() { + if (disable2fa.value.code.length !== 6) { + toast.error('Enter your current 6-digit code to disable 2FA') + return + } + disable2fa.value.busy = true + try { + await api.post('/auth/2fa/disable', { code: disable2fa.value.code }) + await auth.validateSession() + toast.success('Two-factor authentication disabled') + disable2fa.value = { open: false, code: '', busy: false } + } catch (err) { + toast.error('Failed to disable 2FA: ' + (err as Error).message) + } finally { + disable2fa.value.busy = false + } +} + +async function loadApiKeys() { + loadingKeys.value = true + try { + apiKeys.value = await api.get('/api-keys') + } catch (err) { + toast.error('Failed to load API keys: ' + (err as Error).message) + } finally { + loadingKeys.value = false + } +} + +async function createApiKey() { + if (!newKeyName.value.trim()) { + toast.error('Give the key a name') + return + } + creatingKey.value = true + createdKey.value = null + try { + const res = await api.post<{ plaintext_key: string }>('/api-keys', { + name: newKeyName.value.trim(), + }) + createdKey.value = res.plaintext_key + newKeyName.value = '' + await loadApiKeys() + } catch (err) { + toast.error('Failed to create API key: ' + (err as Error).message) + } finally { + creatingKey.value = false + } +} + +async function revokeApiKey(id: string) { + try { + await api.del(`/api-keys/${id}`) + toast.success('API key revoked') + await loadApiKeys() + } catch (err) { + toast.error('Failed to revoke key: ' + (err as Error).message) + } +} + +function copyKey(value: string) { + navigator.clipboard?.writeText(value) + toast.success('Copied to clipboard') +} + onMounted(() => { loadForms() + loadApiKeys() }) const tabItems = [ { value: 'account', label: 'Account', icon: 'user' }, + { value: 'security', label: 'Security', icon: 'shield' }, + { value: 'api', label: 'API', icon: 'code' }, { value: 'license', label: 'License', icon: 'key' }, { value: 'domain', label: 'Domain', icon: 'globe' }, { value: 'public', label: 'Public status', icon: 'eye' }, ] + +const swaggerUrl = '/api/docs' +function openApiDocs() { + window.open(swaggerUrl, '_blank', 'noopener') +}