From fe3d7e905d8f8b67e3b34668d765ef94f4472660 Mon Sep 17 00:00:00 2001 From: Amirreza Gholami Date: Sat, 8 Aug 2026 22:14:48 +0330 Subject: [PATCH] fix(auth): enforce role authorization on panel routes and add unit tests --- .../modules/actions/PostActionsController.kt | 9 +- .../modules/add/PostAddsController.kt | 8 +- .../autocomplete/AutoCompleteController.kt | 69 +-- .../PostConfirmationsController.kt | 9 +- .../custompages/CustomPageController.kt | 6 +- .../modules/download/GetDownloadController.kt | 76 ++-- .../modules/file/PostFilesController.kt | 73 ++-- .../modules/update/PostUpdatesController.kt | 8 +- .../amirroid/ktoradmin/utils/ErrorHandler.kt | 1 - .../ir/amirroid/ktoradmin/validators/User.kt | 2 +- .../ir/amirroid/ktoradmin/TestFixtures.kt | 11 +- .../authentication/PanelAuthorizationTest.kt | 408 ++++++++++++++++++ sample/src/main/kotlin/ir/amirreza/Admin.kt | 2 +- 13 files changed, 549 insertions(+), 133 deletions(-) create mode 100644 KtorAdminLibrary/src/test/kotlin/ir/amirroid/ktoradmin/authentication/PanelAuthorizationTest.kt diff --git a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/actions/PostActionsController.kt b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/actions/PostActionsController.kt index 4e522f96..e6cd5057 100644 --- a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/actions/PostActionsController.kt +++ b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/actions/PostActionsController.kt @@ -39,13 +39,12 @@ internal suspend fun RoutingContext.handleActions(panels: List) { panel == null || panel.isShowInAdminPanel().not() -> { call.notFound("No panel found with plural name: $pluralName") } - // Handle case when action is not found - action == null -> { - call.notFound("Action '$actionName' not found in panel with plural name: $pluralName") - } - // Process the action if both panel and action exist else -> { call.checkHasRole(panel) { + if (action == null) { + call.notFound("Action '$actionName' not found in panel with plural name: $pluralName") + return@checkHasRole + } runCatching { // Parse form data and validate CSRF token val form = call.receiveParameters() diff --git a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/add/PostAddsController.kt b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/add/PostAddsController.kt index fccf18a5..22626a52 100644 --- a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/add/PostAddsController.kt +++ b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/add/PostAddsController.kt @@ -64,11 +64,11 @@ internal suspend fun RoutingContext.handleAddRequest(panels: List) { call.respondText { "No table found with plural name: $pluralName" } return } - if (panel.hasAddAction.not()) { - call.badRequest("Add action is disabled") - return - } call.checkHasRole(panel) { + if (panel.hasAddAction.not()) { + call.badRequest("Add action is disabled") + return@checkHasRole + } when (panel) { is AdminJdbcTable -> insertData(pluralName, panel, panels) is AdminMongoCollection -> insertData(pluralName, panel) diff --git a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/autocomplete/AutoCompleteController.kt b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/autocomplete/AutoCompleteController.kt index 784a5e9d..df6b5fd0 100644 --- a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/autocomplete/AutoCompleteController.kt +++ b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/autocomplete/AutoCompleteController.kt @@ -14,6 +14,7 @@ import ir.amirroid.ktoradmin.panels.AdminPanel import ir.amirroid.ktoradmin.panels.findWithPluralName import ir.amirroid.ktoradmin.repository.JdbcQueriesRepository import ir.amirroid.ktoradmin.utils.withAuthenticate +import ir.amirroid.ktoradmin.validators.checkHasRole import kotlinx.serialization.Serializable @Serializable @@ -66,47 +67,49 @@ internal fun Routing.configureAutoCompleteRouting( return@post } - val columnSet = ownerTable.getAllColumns().find { it.columnName == columnName } - if (columnSet == null || !columnSet.hasAutoComplete) { - call.respondText { "Autocomplete field not found: $columnName" } - return@post - } + call.checkHasRole(ownerTable) { + val columnSet = ownerTable.getAllColumns().find { it.columnName == columnName } + if (columnSet == null || !columnSet.hasAutoComplete) { + call.respondText { "Autocomplete field not found: $columnName" } + return@checkHasRole + } - val reference = columnSet.reference - if (reference == null || (reference !is Reference.ManyToOne && reference !is Reference.OneToOne)) { - call.respondText { "Invalid reference type for autocomplete field: $columnName" } - return@post - } + val reference = columnSet.reference + if (reference == null || (reference !is Reference.ManyToOne && reference !is Reference.OneToOne)) { + call.respondText { "Invalid reference type for autocomplete field: $columnName" } + return@checkHasRole + } - val referencedTableName = reference.tableName - val referencedTable = - panels.find { panel -> - panel is AdminJdbcTable && panel.getTableName() == referencedTableName - } as? AdminJdbcTable + val referencedTableName = reference.tableName + val referencedTable = + panels.find { panel -> + panel is AdminJdbcTable && panel.getTableName() == referencedTableName + } as? AdminJdbcTable - if (referencedTable == null) { - call.respondText { "Referenced table not found: $referencedTableName" } - return@post - } + if (referencedTable == null) { + call.respondText { "Referenced table not found: $referencedTableName" } + return@checkHasRole + } - val request = call.receive() - val search = request.search.takeIf { it.isNotBlank() } - val page = request.page.coerceAtLeast(0) + val request = call.receive() + val search = request.search.takeIf { it.isNotBlank() } + val page = request.page.coerceAtLeast(0) - val pageSize = DynamicConfiguration.autocompletePageSize + val pageSize = DynamicConfiguration.autocompletePageSize - val searchFields = columnSet.autoCompleteSearchFields + val searchFields = columnSet.autoCompleteSearchFields - val results = - JdbcQueriesRepository.searchReferences( - table = referencedTable, - search = search, - page = page, - pageSize = pageSize, - searchFields = searchFields, - ) + val results = + JdbcQueriesRepository.searchReferences( + table = referencedTable, + search = search, + page = page, + pageSize = pageSize, + searchFields = searchFields, + ) - call.respond(results) + call.respond(results) + } } } } diff --git a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/confirmation/PostConfirmationsController.kt b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/confirmation/PostConfirmationsController.kt index 6b395fda..5c65b77a 100644 --- a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/confirmation/PostConfirmationsController.kt +++ b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/confirmation/PostConfirmationsController.kt @@ -55,12 +55,11 @@ internal suspend fun RoutingContext.handleSaveConfirmation(panels: List { - if (!panel.hasEditAction) { - call.badRequest("Edit action is disabled") - return - } - call.checkHasRole(panel) { + if (!panel.hasEditAction) { + call.badRequest("Edit action is disabled") + return@checkHasRole + } val parameters = call.receiveParameters() val csrfToken = parameters[CSRF_TOKEN_FIELD_NAME] diff --git a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/custompages/CustomPageController.kt b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/custompages/CustomPageController.kt index 8d8ec6f5..b8cbdabe 100644 --- a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/custompages/CustomPageController.kt +++ b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/custompages/CustomPageController.kt @@ -35,9 +35,9 @@ internal suspend fun ApplicationCall.handleCustomPage( val user = principal() val username = user?.name - if (page.permissions != null && user != null) { - val userRoles = user.roles ?: emptyList() - if (page.permissions.none { it in userRoles }) { + if (page.permissions != null) { + val userRoles = user?.roles ?: emptyList() + if (user == null || page.permissions.none { it in userRoles }) { return forbidden("You do not have permission to access this page.") } } diff --git a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/download/GetDownloadController.kt b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/download/GetDownloadController.kt index 0144a150..65b5c27e 100644 --- a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/download/GetDownloadController.kt +++ b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/download/GetDownloadController.kt @@ -19,6 +19,7 @@ import ir.amirroid.ktoradmin.utils.invalidateRequest import ir.amirroid.ktoradmin.utils.notFound import ir.amirroid.ktoradmin.utils.serverError import ir.amirroid.ktoradmin.utils.withAuthenticate +import ir.amirroid.ktoradmin.validators.checkHasRole fun Routing.configureDownloadFilesRouting( authenticateName: String?, @@ -32,26 +33,28 @@ fun Routing.configureDownloadFilesRouting( return@get call.badRequest("To use this feature, please enable this option in the configuration.") } val pluralName = call.parameters["pluralName"] - val csrfToken = call.parameters[CSRF_TOKEN_FIELD_NAME] - if (CsrfManager.validateToken(csrfToken).not()) { - return@get call.invalidateRequest() - } val panel = panels.find { it.getPluralName() == pluralName } if (panel == null || panel.isShowInAdminPanel().not()) { call.notFound("No table found with plural name: $pluralName") } else { - call.response.header( - HttpHeaders.ContentDisposition, - "attachment; filename=\"${pluralName}_data.csv\"", - ) - val file = - when (panel) { - is AdminJdbcTable -> JdbcQueriesRepository.getAllDataAsCsvFile(panel) - is AdminMongoCollection -> MongoClientRepository.getAllDataAsCsvFile(panel) - else -> "return@get" + call.checkHasRole(panel) { + val csrfToken = call.parameters[CSRF_TOKEN_FIELD_NAME] + if (CsrfManager.validateToken(csrfToken).not()) { + return@checkHasRole call.invalidateRequest() } - val bytes = file.toByteArray() - call.respondBytes(contentType = ContentType.Text.CSV) { bytes } + call.response.header( + HttpHeaders.ContentDisposition, + "attachment; filename=\"${pluralName}_data.csv\"", + ) + val file = + when (panel) { + is AdminJdbcTable -> JdbcQueriesRepository.getAllDataAsCsvFile(panel) + is AdminMongoCollection -> MongoClientRepository.getAllDataAsCsvFile(panel) + else -> "return@checkHasRole" + } + val bytes = file.toByteArray() + call.respondBytes(contentType = ContentType.Text.CSV) { bytes } + } } }.onFailure { call.serverError(it.message.orEmpty(), it) @@ -66,33 +69,36 @@ fun Routing.configureDownloadFilesRouting( } val pluralName = call.parameters["pluralName"] val primaryKey = call.parameters["primaryKey"] ?: return@get call.badRequest("Primary key is missing") - val csrfToken = call.parameters[CSRF_TOKEN_FIELD_NAME] - - if (!CsrfManager.validateToken(csrfToken)) { - return@get call.invalidateRequest() - } val panel = panels.find { it.getPluralName() == pluralName }?.takeIf { it.isShowInAdminPanel() } ?: return@get call.notFound("No table found with plural name: $pluralName") - val font = - (DynamicConfiguration.template as? DefaultAdminTemplate) - ?.settings - ?.typography - ?.font - val regularFontPath = font?.regular ?: "/static/font/IstokWeb-Regular.ttf" - val boldFontPath = font?.bold ?: "/static/font/IstokWeb-Bold.ttf" + call.checkHasRole(panel) { + val csrfToken = call.parameters[CSRF_TOKEN_FIELD_NAME] - val pdfData = - PdfHelper.generatePdf(panel, primaryKey, call, regularFontPath, boldFontPath) - ?: return@get call.badRequest("Error generating PDF") + if (!CsrfManager.validateToken(csrfToken)) { + return@checkHasRole call.invalidateRequest() + } - call.response.header( - HttpHeaders.ContentDisposition, - "attachment; filename=\"output_${pluralName}_$primaryKey.pdf\"", - ) - call.respondBytes(pdfData, ContentType.Application.Pdf) + val font = + (DynamicConfiguration.template as? DefaultAdminTemplate) + ?.settings + ?.typography + ?.font + val regularFontPath = font?.regular ?: "/static/font/IstokWeb-Regular.ttf" + val boldFontPath = font?.bold ?: "/static/font/IstokWeb-Bold.ttf" + + val pdfData = + PdfHelper.generatePdf(panel, primaryKey, this, regularFontPath, boldFontPath) + ?: return@checkHasRole call.badRequest("Error generating PDF") + + call.response.header( + HttpHeaders.ContentDisposition, + "attachment; filename=\"output_${pluralName}_$primaryKey.pdf\"", + ) + call.respondBytes(pdfData, ContentType.Application.Pdf) + } }.onFailure { call.serverError(it.message.orEmpty(), it) } diff --git a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/file/PostFilesController.kt b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/file/PostFilesController.kt index 168cf002..0490f92c 100644 --- a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/file/PostFilesController.kt +++ b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/file/PostFilesController.kt @@ -11,6 +11,7 @@ import ir.amirroid.ktoradmin.panels.AdminPanel import ir.amirroid.ktoradmin.repository.FileRepository import ir.amirroid.ktoradmin.utils.notFound import ir.amirroid.ktoradmin.utils.withAuthenticate +import ir.amirroid.ktoradmin.validators.checkHasRole fun Routing.handleGenerateFileUrl( panels: List, @@ -36,49 +37,47 @@ fun Routing.handleGenerateFileUrl( return@post } val (pluralName, itemName) = field - val itemUploadTarget = - panels - .firstOrNull { - it.getPluralName() == pluralName - }?.let { panel -> - if (panel.isShowInAdminPanel().not()) { - return@post call.notFound("No table found with plural name: $pluralName") - } - when (panel) { - is AdminJdbcTable -> - panel - .getAllColumns() - .firstOrNull { - it.columnName == itemName - }?.uploadTarget + val panel = panels.firstOrNull { it.getPluralName() == pluralName } + if (panel == null || panel.isShowInAdminPanel().not()) { + return@post call.notFound("No table found with plural name: $pluralName") + } + call.checkHasRole(panel) { + val itemUploadTarget = + when (panel) { + is AdminJdbcTable -> + panel + .getAllColumns() + .firstOrNull { + it.columnName == itemName + }?.uploadTarget - is AdminMongoCollection -> - panel - .getAllFields() - .firstOrNull { - it.fieldName == itemName - }?.uploadTarget + is AdminMongoCollection -> + panel + .getAllFields() + .firstOrNull { + it.fieldName == itemName + }?.uploadTarget - else -> null - } + else -> null } - if (itemUploadTarget == null) { - call.respond( - message = mapOf("ir/amirroid/ktoradmin/errorirroid/ktoradmin/error" to "Field does not exist"), - status = HttpStatusCode.BadRequest, - ) - return@post - } - FileRepository - .generateMediaUrl( - fileName = fileName, - uploadTarget = itemUploadTarget, - call = call, - ).let { + if (itemUploadTarget == null) { call.respond( - mapOf("url" to it), + message = mapOf("ir/amirroid/ktoradmin/errorirroid/ktoradmin/error" to "Field does not exist"), + status = HttpStatusCode.BadRequest, ) + return@checkHasRole } + FileRepository + .generateMediaUrl( + fileName = fileName, + uploadTarget = itemUploadTarget, + call = call, + ).let { + call.respond( + mapOf("url" to it), + ) + } + } } } } diff --git a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/update/PostUpdatesController.kt b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/update/PostUpdatesController.kt index b0ac27ad..4f8bc541 100644 --- a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/update/PostUpdatesController.kt +++ b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/modules/update/PostUpdatesController.kt @@ -71,11 +71,11 @@ internal suspend fun RoutingContext.handleUpdateRequest(panels: List call.notFound("No table found with plural name: $pluralName") return } - if (panel.hasEditAction.not()) { - call.badRequest("Edit action is disabled") - return - } call.checkHasRole(panel) { + if (panel.hasEditAction.not()) { + call.badRequest("Edit action is disabled") + return@checkHasRole + } runCatching { when (panel) { is AdminJdbcTable -> updateData(pluralName, primaryKey, panel, panels) diff --git a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/utils/ErrorHandler.kt b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/utils/ErrorHandler.kt index 0d8f1312..76b51f3b 100644 --- a/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/utils/ErrorHandler.kt +++ b/KtorAdminLibrary/src/main/kotlin/ir/amirroid/ktoradmin/utils/ErrorHandler.kt @@ -62,7 +62,6 @@ private fun generateErrorHtml( """ -