diff --git a/build.gradle b/build.gradle index c927d04987..b9d73bfdf5 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,7 @@ buildscript { ext.beanutils_version = '1.9.3' ext.crash_version = 'cce5a00f114343c1145c1d7756e1dd6df3ea984e' ext.jsr305_version = constants.getProperty("jsr305Version") + ext.shiro_version = '1.4.0' ext.artifactory_plugin_version = constants.getProperty('artifactoryPluginVersion') // Update 121 is required for ObjectInputFilter and at time of writing 131 was latest: diff --git a/client/rpc/src/integration-test/kotlin/net/corda/client/rpc/RPCStabilityTests.kt b/client/rpc/src/integration-test/kotlin/net/corda/client/rpc/RPCStabilityTests.kt index a6ff6eb80d..9970334fd8 100644 --- a/client/rpc/src/integration-test/kotlin/net/corda/client/rpc/RPCStabilityTests.kt +++ b/client/rpc/src/integration-test/kotlin/net/corda/client/rpc/RPCStabilityTests.kt @@ -63,8 +63,8 @@ class RPCStabilityTests { val executor = Executors.newScheduledThreadPool(1) fun startAndStop() { rpcDriver { - val server = startRpcServer(ops = DummyOps) - startRpcClient(server.get().broker.hostAndPort!!).get() + val server = startRpcServer(ops = DummyOps).get() + startRpcClient(server.broker.hostAndPort!!).get() } } repeat(5) { diff --git a/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/RPCClientProxyHandler.kt b/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/RPCClientProxyHandler.kt index a85a6342b2..9de4fd6afb 100644 --- a/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/RPCClientProxyHandler.kt +++ b/client/rpc/src/main/kotlin/net/corda/client/rpc/internal/RPCClientProxyHandler.kt @@ -22,7 +22,10 @@ import net.corda.core.internal.ThreadBox import net.corda.core.messaging.RPCOps import net.corda.core.serialization.SerializationContext import net.corda.core.serialization.serialize -import net.corda.core.utilities.* +import net.corda.core.utilities.Try +import net.corda.core.utilities.contextLogger +import net.corda.core.utilities.debug +import net.corda.core.utilities.getOrThrow import net.corda.nodeapi.ArtemisConsumer import net.corda.nodeapi.ArtemisProducer import net.corda.nodeapi.RPCApi diff --git a/client/rpc/src/test/kotlin/net/corda/client/rpc/RPCPermissionsTests.kt b/client/rpc/src/test/kotlin/net/corda/client/rpc/RPCPermissionsTests.kt index c7c1e71346..0607541ba9 100644 --- a/client/rpc/src/test/kotlin/net/corda/client/rpc/RPCPermissionsTests.kt +++ b/client/rpc/src/test/kotlin/net/corda/client/rpc/RPCPermissionsTests.kt @@ -1,8 +1,6 @@ package net.corda.client.rpc -import net.corda.core.messaging.CordaRPCOps import net.corda.core.messaging.RPCOps -import net.corda.node.services.Permissions.Companion.invokeRpc import net.corda.node.services.messaging.rpcContext import net.corda.nodeapi.internal.config.User import net.corda.testing.internal.RPCDriverDSL @@ -10,15 +8,12 @@ import net.corda.testing.internal.rpcDriver import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized -import kotlin.reflect.KVisibility -import kotlin.reflect.full.declaredMemberFunctions import kotlin.test.assertFailsWith @RunWith(Parameterized::class) class RPCPermissionsTests : AbstractRPCTest() { companion object { const val DUMMY_FLOW = "StartFlow.net.corda.flows.DummyFlow" - const val OTHER_FLOW = "StartFlow.net.corda.flows.OtherFlow" const val ALL_ALLOWED = "ALL" } @@ -26,12 +21,21 @@ class RPCPermissionsTests : AbstractRPCTest() { * RPC operation. */ interface TestOps : RPCOps { - fun validatePermission(str: String) + fun validatePermission(method: String, target: String? = null) } class TestOpsImpl : TestOps { override val protocolVersion = 1 - override fun validatePermission(str: String) { rpcContext().requirePermission(str) } + override fun validatePermission(method: String, target: String?) { + val authorized = if (target == null) { + rpcContext().isPermitted(method) + } else { + rpcContext().isPermitted(method, target) + } + if (!authorized) { + throw PermissionException("RPC user not authorized") + } + } } /** @@ -46,9 +50,9 @@ class RPCPermissionsTests : AbstractRPCTest() { rpcDriver { val emptyUser = userOf("empty", emptySet()) val proxy = testProxyFor(emptyUser) - assertFailsWith(PermissionException::class, - "User ${emptyUser.username} should not be allowed to use $DUMMY_FLOW.", - { proxy.validatePermission(DUMMY_FLOW) }) + assertNotAllowed { + proxy.validatePermission("startFlowDynamic", "net.corda.flows.DummyFlow") + } } } @@ -57,7 +61,8 @@ class RPCPermissionsTests : AbstractRPCTest() { rpcDriver { val adminUser = userOf("admin", setOf(ALL_ALLOWED)) val proxy = testProxyFor(adminUser) - proxy.validatePermission(DUMMY_FLOW) + proxy.validatePermission("startFlowDynamic", "net.corda.flows.DummyFlow") + proxy.validatePermission("startTrackedFlowDynamic", "net.corda.flows.DummyFlow") } } @@ -66,7 +71,8 @@ class RPCPermissionsTests : AbstractRPCTest() { rpcDriver { val joeUser = userOf("joe", setOf(DUMMY_FLOW)) val proxy = testProxyFor(joeUser) - proxy.validatePermission(DUMMY_FLOW) + proxy.validatePermission("startFlowDynamic", "net.corda.flows.DummyFlow") + proxy.validatePermission("startTrackedFlowDynamic", "net.corda.flows.DummyFlow") } } @@ -75,36 +81,46 @@ class RPCPermissionsTests : AbstractRPCTest() { rpcDriver { val joeUser = userOf("joe", setOf(DUMMY_FLOW)) val proxy = testProxyFor(joeUser) - assertFailsWith(PermissionException::class, - "User ${joeUser.username} should not be allowed to use $OTHER_FLOW", - { proxy.validatePermission(OTHER_FLOW) }) - } - } - - @Test - fun `check ALL is implemented the correct way round`() { - rpcDriver { - val joeUser = userOf("joe", setOf(DUMMY_FLOW)) - val proxy = testProxyFor(joeUser) - assertFailsWith(PermissionException::class, - "Permission $ALL_ALLOWED should not do anything for User ${joeUser.username}", - { proxy.validatePermission(ALL_ALLOWED) }) - } - } - - @Test - fun `fine grained permissions are enforced`() { - val allPermissions = CordaRPCOps::class.declaredMemberFunctions.filter { it.visibility == KVisibility.PUBLIC }.map { invokeRpc(it) } - allPermissions.forEach { permission -> - rpcDriver { - val user = userOf("Mark", setOf(permission)) - val proxy = testProxyFor(user) - - proxy.validatePermission(permission) - (allPermissions - permission).forEach { notOwnedPermission -> - assertFailsWith(PermissionException::class, { proxy.validatePermission(notOwnedPermission) }) - } + assertNotAllowed { + proxy.validatePermission("startFlowDynamic", "net.corda.flows.OtherFlow") + } + assertNotAllowed { + proxy.validatePermission("startTrackedFlowDynamic", "net.corda.flows.OtherFlow") } } } + + @Test + fun `joe user is not allowed to call other RPC methods`() { + rpcDriver { + val joeUser = userOf("joe", setOf(DUMMY_FLOW)) + val proxy = testProxyFor(joeUser) + assertNotAllowed { + proxy.validatePermission("nodeInfo") + } + assertNotAllowed { + proxy.validatePermission("networkMapFeed") + } + } + } + + @Test + fun `checking invokeRpc permissions entitlements`() { + rpcDriver { + val joeUser = userOf("joe", setOf("InvokeRpc.networkMapFeed")) + val proxy = testProxyFor(joeUser) + assertNotAllowed { + proxy.validatePermission("nodeInfo") + } + assertNotAllowed { + proxy.validatePermission("startTrackedFlowDynamic", "net.corda.flows.OtherFlow") + } + proxy.validatePermission("networkMapFeed") + } + } + + private fun assertNotAllowed(action: () -> Unit) { + + assertFailsWith(PermissionException::class, "User should not be allowed to perform this action.", action) + } } diff --git a/docs/source/corda-nodes-index.rst b/docs/source/corda-nodes-index.rst index c1dfa0b508..061902b1aa 100644 --- a/docs/source/corda-nodes-index.rst +++ b/docs/source/corda-nodes-index.rst @@ -10,6 +10,7 @@ Corda nodes corda-configuration-file clientrpc shell + node-auth-config node-database node-administration out-of-process-verification \ No newline at end of file diff --git a/docs/source/node-auth-config.rst b/docs/source/node-auth-config.rst new file mode 100644 index 0000000000..5f9c54c3e4 --- /dev/null +++ b/docs/source/node-auth-config.rst @@ -0,0 +1,136 @@ +Access security settings +======================== + +Access to node functionalities via SSH or RPC is protected by an authentication and authorisation policy. + +The field ``security`` in ``node.conf`` exposes various sub-fields related to authentication/authorisation specifying: + + * The data source providing credentials and permissions for users (e.g.: a remote RDBMS) + * An optional password encryption method. + * An optional caching of users data from Node side. + +.. warning:: Specifying both ``rpcUsers`` and ``security`` fields in ``node.conf`` is considered an illegal setting and + rejected by the node at startup since ``rpcUsers`` is effectively deprecated in favour of ``security.authService``. + +**Example 1:** connect to remote RDBMS for credentials/permissions, with encrypted user passwords and +caching on node-side: + +.. container:: codeset + + .. sourcecode:: groovy + + security = { + authService = { + dataSource = { + type = "DB", + passwordEncryption = "SHIRO_1_CRYPT", + connection = { + jdbcUrl = "" + username = "" + password = "" + driverClassName = "" + } + } + options = { + cache = { + expiryTimeSecs = 120 + capacity = 10000 + } + } + } + } + +**Example 2:** list of user credentials and permissions hard-coded in ``node.conf`` + +.. container:: codeset + + .. sourcecode:: groovy + + security = { + authService = { + dataSource = { + type = "INMEMORY", + users =[ + { + username = "user1" + password = "password" + permissions = [ + "StartFlow.net.corda.flows.ExampleFlow1", + "StartFlow.net.corda.flows.ExampleFlow2", + ... + ] + }, + ... + ] + } + } + } + +Let us look in more details at the structure of ``security.authService``: + +Authentication/authorisation data +--------------------------------- + +The ``dataSource`` field defines the data provider supplying credentials and permissions for users. The ``type`` +subfield identify the type of data provider, currently supported one are: + + * **INMEMORY:** a list of user credentials and permissions hard-coded in configuration in the ``users`` field + (see example 2 above) + + * **DB:** An external RDBMS accessed via the JDBC connection described by ``connection``. The current implementation + expect the database to store data according to the following schema: + + - Table ``users`` containing columns ``username`` and ``password``. + The ``username`` column *must have unique values*. + - Table ``user_roles`` containing columns ``username`` and ``role_name`` associating a user to a set of *roles* + - Table ``roles_permissions`` containing columns ``role_name`` and ``permission`` associating a role to a set of + permission strings + + Note in particular how in the DB case permissions are assigned to _roles_ rather than individual users. + Also, there is no prescription on the SQL type of the columns (although in our tests we defined ``username`` and + ``role_name`` of SQL type ``VARCHAR`` and ``password`` of ``TEXT`` type) and it is allowed to put additional columns + besides the one expected by the implementation. + +Password encryption +------------------- + +Storing passwords in plain text is discouraged in production systems aiming for high security requirements. We support +reading passwords stored using the Apache Shiro fully reversible Modular Crypt Format, specified in the documentation +of ``org.apache.shiro.crypto.hash.format.Shiro1CryptFormat``. + +Password are assumed in plain format by default. To specify an encryption it is necessary to use the field: + +.. container:: codeset + + .. sourcecode:: groovy + + passwordEncryption = SHIRO_1_CRYPT + +Hash encrypted password based on the Shiro1CryptFormat can be produced with the `Apache Shiro Hasher tool `_ + +Cache +----- + +Adding a cache layer on top of an external provider of users credentials and permissions can significantly benefit +performances in some cases, with the disadvantage of introducing a latency in the propagation of changes to the data. + +Caching of users data is disabled by default, it can be enabled by defining the ``options.cache`` field, like seen in +the examples above: + +.. container:: codeset + + .. sourcecode:: groovy + + options = { + cache = { + expiryTimeSecs = 120 + capacity = 10000 + } + } + +This will enable an in-memory cache with maximum capacity (number of entries) and maximum life time of entries given by +respectively the values set by the ``capacity`` and ``expiryTimeSecs`` fields. + + + + diff --git a/node-api/src/main/kotlin/net/corda/nodeapi/internal/config/ConfigUtilities.kt b/node-api/src/main/kotlin/net/corda/nodeapi/internal/config/ConfigUtilities.kt index 33a5e237ff..53a9ab7ce2 100644 --- a/node-api/src/main/kotlin/net/corda/nodeapi/internal/config/ConfigUtilities.kt +++ b/node-api/src/main/kotlin/net/corda/nodeapi/internal/config/ConfigUtilities.kt @@ -80,6 +80,7 @@ private fun Config.getSingleValue(path: String, type: KType): Any? { URL::class -> URL(getString(path)) CordaX500Name::class -> CordaX500Name.parse(getString(path)) Properties::class -> getConfig(path).toProperties() + Config::class -> getConfig(path) else -> if (typeClass.java.isEnum) { parseEnum(typeClass.java, getString(path)) } else { diff --git a/node/build.gradle b/node/build.gradle index 91a5000f3d..2aeb7878d3 100644 --- a/node/build.gradle +++ b/node/build.gradle @@ -162,6 +162,9 @@ dependencies { // FastClasspathScanner: classpath scanning compile 'io.github.lukehutch:fast-classpath-scanner:2.0.21' + // Apache Shiro: authentication, authorization and session management. + compile "org.apache.shiro:shiro-core:${shiro_version}" + // Integration test helpers integrationTestCompile "junit:junit:$junit_version" integrationTestCompile "org.assertj:assertj-core:${assertj_version}" diff --git a/node/src/integration-test/kotlin/net/corda/node/SSHServerTest.kt b/node/src/integration-test/kotlin/net/corda/node/SSHServerTest.kt index 67b1996596..585f045eb9 100644 --- a/node/src/integration-test/kotlin/net/corda/node/SSHServerTest.kt +++ b/node/src/integration-test/kotlin/net/corda/node/SSHServerTest.kt @@ -20,6 +20,7 @@ import kotlin.test.assertTrue import kotlin.test.fail import org.assertj.core.api.Assertions.assertThat import java.util.regex.Pattern +import kotlin.reflect.jvm.jvmName class SSHServerTest { @@ -113,7 +114,7 @@ class SSHServerTest { channel.disconnect() session.disconnect() - assertThat(response).matches("(?s)User not permissioned with any of \\[[^]]*${flowNameEscaped}.*") + assertThat(response).matches("(?s)User not authorized to perform RPC call .*") } } diff --git a/node/src/integration-test/kotlin/net/corda/node/services/UserAuthServiceTests.kt b/node/src/integration-test/kotlin/net/corda/node/services/UserAuthServiceTests.kt new file mode 100644 index 0000000000..75b86c7d94 --- /dev/null +++ b/node/src/integration-test/kotlin/net/corda/node/services/UserAuthServiceTests.kt @@ -0,0 +1,303 @@ +package net.corda.node.services + +import co.paralleluniverse.fibers.Suspendable +import net.corda.client.rpc.CordaRPCClient +import net.corda.client.rpc.PermissionException +import net.corda.core.flows.FlowLogic +import net.corda.core.flows.InitiatingFlow +import net.corda.core.flows.StartableByRPC +import net.corda.core.messaging.CordaRPCOps +import net.corda.core.messaging.startFlow +import net.corda.finance.flows.CashIssueFlow +import net.corda.node.internal.Node +import net.corda.node.internal.StartedNode +import net.corda.node.services.config.PasswordEncryption +import net.corda.node.services.config.SecurityConfiguration +import net.corda.node.services.config.AuthDataSourceType +import net.corda.nodeapi.internal.config.User +import net.corda.nodeapi.internal.config.toConfig +import net.corda.testing.internal.NodeBasedTest +import net.corda.testing.* +import org.apache.activemq.artemis.api.core.ActiveMQSecurityException +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.sql.DriverManager +import java.sql.Statement +import java.util.* +import kotlin.test.assertFailsWith + +abstract class UserAuthServiceTest : NodeBasedTest() { + + protected lateinit var node: StartedNode + protected lateinit var client: CordaRPCClient + + @Test + fun `login with correct credentials`() { + client.start("user", "foo") + } + + @Test + fun `login with wrong credentials`() { + client.start("user", "foo") + assertFailsWith( + ActiveMQSecurityException::class, + "Login with incorrect password should fail") { + client.start("user", "bar") + } + assertFailsWith( + ActiveMQSecurityException::class, + "Login with unknown username should fail") { + client.start("X", "foo") + } + } + + @Test + fun `check flow permissions are respected`() { + client.start("user", "foo").use { + val proxy = it.proxy + proxy.startFlowDynamic(DummyFlow::class.java) + proxy.startTrackedFlowDynamic(DummyFlow::class.java) + proxy.startFlow(::DummyFlow) + assertFailsWith( + PermissionException::class, + "This user should not be authorized to start flow `CashIssueFlow`") { + proxy.startFlowDynamic(CashIssueFlow::class.java) + } + assertFailsWith( + PermissionException::class, + "This user should not be authorized to start flow `CashIssueFlow`") { + proxy.startTrackedFlowDynamic(CashIssueFlow::class.java) + } + } + } + + @Test + fun `check permissions on RPC calls are respected`() { + client.start("user", "foo").use { + val proxy = it.proxy + proxy.stateMachinesFeed() + assertFailsWith( + PermissionException::class, + "This user should not be authorized to call 'nodeInfo'") { + proxy.nodeInfo() + } + } + } + + @StartableByRPC + @InitiatingFlow + class DummyFlow : FlowLogic() { + @Suspendable + override fun call() = Unit + } +} + +class UserAuthServiceEmbedded : UserAuthServiceTest() { + + private val rpcUser = User("user", "foo", permissions = setOf( + Permissions.startFlow(), + Permissions.invokeRpc("vaultQueryBy"), + Permissions.invokeRpc(CordaRPCOps::stateMachinesFeed), + Permissions.invokeRpc("vaultQueryByCriteria"))) + + @Before + fun setup() { + val securityConfig = SecurityConfiguration( + authService = SecurityConfiguration.AuthService.fromUsers(listOf(rpcUser))) + + val configOverrides = mapOf("security" to securityConfig.toConfig().root().unwrapped()) + node = startNode(ALICE_NAME, rpcUsers = emptyList(), configOverrides = configOverrides) + client = CordaRPCClient(node.internals.configuration.rpcAddress!!) + } +} + +class UserAuthServiceTestsJDBC : UserAuthServiceTest() { + + private val db = UsersDB( + name = "SecurityDataSourceTestDB", + users = listOf(UserAndRoles(username = "user", + password = "foo", + roles = listOf("default"))), + roleAndPermissions = listOf( + RoleAndPermissions( + role = "default", + permissions = listOf( + Permissions.startFlow(), + Permissions.invokeRpc("vaultQueryBy"), + Permissions.invokeRpc(CordaRPCOps::stateMachinesFeed), + Permissions.invokeRpc("vaultQueryByCriteria"))), + RoleAndPermissions( + role = "admin", + permissions = listOf("ALL") + ))) + + @Before + fun setup() { + val securityConfig = SecurityConfiguration( + authService = SecurityConfiguration.AuthService( + dataSource = SecurityConfiguration.AuthService.DataSource( + type = AuthDataSourceType.DB, + passwordEncryption = PasswordEncryption.NONE, + connection = Properties().apply { + setProperty("jdbcUrl", db.jdbcUrl) + setProperty("username", "") + setProperty("password", "") + setProperty("driverClassName", "org.h2.Driver") + } + ) + ) + ) + + val configOverrides = mapOf("security" to securityConfig.toConfig().root().unwrapped()) + node = startNode(ALICE_NAME, rpcUsers = emptyList(), configOverrides = configOverrides) + client = CordaRPCClient(node.internals.configuration.rpcAddress!!) + } + + @Test + fun `Add new users on-the-fly`() { + assertFailsWith( + ActiveMQSecurityException::class, + "Login with incorrect password should fail") { + client.start("user2", "bar") + } + + db.insert(UserAndRoles( + username = "user2", + password = "bar", + roles = listOf("default"))) + + client.start("user2", "bar") + } + + @Test + fun `Modify user permissions during RPC session`() { + db.insert(UserAndRoles( + username = "user3", + password = "bar", + roles = emptyList())) + + + client.start("user3", "bar").use { + val proxy = it.proxy + assertFailsWith( + PermissionException::class, + "This user should not be authorized to call 'nodeInfo'") { + proxy.stateMachinesFeed() + } + db.addRoleToUser("user3", "default") + proxy.stateMachinesFeed() + } + } + + @Test + fun `Revoke user permissions during RPC session`() { + db.insert(UserAndRoles( + username = "user4", + password = "test", + roles = listOf("default"))) + + client.start("user4", "test").use { + val proxy = it.proxy + proxy.stateMachinesFeed() + db.deleteUser("user4") + assertFailsWith( + PermissionException::class, + "This user should not be authorized to call 'nodeInfo'") { + proxy.stateMachinesFeed() + } + } + } + + @After + fun tearDown() { + db.close() + } +} + +private data class UserAndRoles(val username: String, val password: String, val roles: List) +private data class RoleAndPermissions(val role: String, val permissions: List) + +private class UsersDB : AutoCloseable { + + val jdbcUrl: String + + companion object { + val DB_CREATE_SCHEMA = """ + CREATE TABLE users (username VARCHAR(256), password TEXT); + CREATE TABLE user_roles (username VARCHAR(256), role_name VARCHAR(256)); + CREATE TABLE roles_permissions (role_name VARCHAR(256), permission TEXT); + """ + } + + fun insert(user: UserAndRoles) { + session { + it.execute("INSERT INTO users VALUES ('${user.username}', '${user.password}')") + for (role in user.roles) { + it.execute("INSERT INTO user_roles VALUES ('${user.username}', '${role}')") + } + } + } + + fun insert(roleAndPermissions: RoleAndPermissions) { + val (role, permissions) = roleAndPermissions + session { + for (permission in permissions) { + it.execute("INSERT INTO roles_permissions VALUES ('$role', '$permission')") + } + } + } + + fun addRoleToUser(username: String, role: String) { + session { + it.execute("INSERT INTO user_roles VALUES ('$username', '$role')") + } + } + + fun deleteRole(role: String) { + session { + it.execute("DELETE FROM role_permissions WHERE role_name = '$role'") + } + } + + fun deleteUser(username: String) { + session { + it.execute("DELETE FROM users WHERE username = '$username'") + it.execute("DELETE FROM user_roles WHERE username = '$username'") + } + } + + inline private fun session(statement: (Statement) -> Unit) { + DriverManager.getConnection(jdbcUrl).use { + it.autoCommit = false + it.createStatement().use(statement) + it.commit() + } + } + + constructor(name: String, + users: List = emptyList(), + roleAndPermissions: List = emptyList()) { + + jdbcUrl = "jdbc:h2:mem:${name};DB_CLOSE_DELAY=-1" + + session { + it.execute(DB_CREATE_SCHEMA) + } + + require(users.map { it.username }.toSet().size == users.size) { + "Duplicate username in input" + } + + users.forEach { insert(it) } + roleAndPermissions.forEach { insert(it) } + } + + override fun close() { + DriverManager.getConnection(jdbcUrl).use { + it.createStatement().use { + it.execute("DROP ALL OBJECTS") + } + } + } +} \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/internal/AbstractNode.kt b/node/src/main/kotlin/net/corda/node/internal/AbstractNode.kt index 9699318f73..00b24dba65 100644 --- a/node/src/main/kotlin/net/corda/node/internal/AbstractNode.kt +++ b/node/src/main/kotlin/net/corda/node/internal/AbstractNode.kt @@ -38,7 +38,7 @@ import net.corda.node.internal.cordapp.CordappProviderInternal import net.corda.node.services.ContractUpgradeHandler import net.corda.node.services.FinalityHandler import net.corda.node.services.NotaryChangeHandler -import net.corda.node.services.RPCUserService +import net.corda.node.internal.security.RPCSecurityManager import net.corda.node.services.api.* import net.corda.node.services.config.BFTSMaRtConfiguration import net.corda.node.services.config.NodeConfiguration @@ -137,7 +137,7 @@ abstract class AbstractNode(val configuration: NodeConfiguration, protected val _nodeReadyFuture = openFuture() protected val networkMapClient: NetworkMapClient? by lazy { configuration.compatibilityZoneURL?.let(::NetworkMapClient) } - lateinit var userService: RPCUserService get + lateinit var securityManager: RPCSecurityManager get /** Completes once the node has successfully registered with the network map service * or has loaded network map data from local database */ @@ -265,7 +265,7 @@ abstract class AbstractNode(val configuration: NodeConfiguration, protected abstract fun getRxIoScheduler(): Scheduler open fun startShell(rpcOps: CordaRPCOps) { - InteractiveShell.startShell(configuration, rpcOps, userService, _services.identityService, _services.database) + InteractiveShell.startShell(configuration, rpcOps, securityManager, _services.identityService, _services.database) } private fun initNodeInfo(networkMapCache: NetworkMapCacheBaseInternal, diff --git a/node/src/main/kotlin/net/corda/node/internal/Node.kt b/node/src/main/kotlin/net/corda/node/internal/Node.kt index 2ddba4a393..deca9b1922 100644 --- a/node/src/main/kotlin/net/corda/node/internal/Node.kt +++ b/node/src/main/kotlin/net/corda/node/internal/Node.kt @@ -2,6 +2,7 @@ package net.corda.node.internal import com.codahale.metrics.JmxReporter import net.corda.core.concurrent.CordaFuture +import net.corda.core.context.AuthServiceId import net.corda.core.internal.concurrent.openFuture import net.corda.core.internal.concurrent.thenMatch import net.corda.core.internal.uncheckedCast @@ -16,11 +17,10 @@ import net.corda.core.utilities.NetworkHostAndPort import net.corda.core.utilities.contextLogger import net.corda.node.VersionInfo import net.corda.node.internal.cordapp.CordappLoader +import net.corda.node.internal.security.RPCSecurityManagerImpl import net.corda.node.serialization.KryoServerSerializationScheme -import net.corda.node.services.RPCUserServiceImpl import net.corda.node.services.api.SchemaService -import net.corda.node.services.config.NodeConfiguration -import net.corda.node.services.config.VerifierType +import net.corda.node.services.config.* import net.corda.node.services.messaging.* import net.corda.node.services.transactions.InMemoryTransactionVerifierService import net.corda.node.utilities.AddressUtils @@ -133,7 +133,12 @@ open class Node(configuration: NodeConfiguration, private var shutdownHook: ShutdownHook? = null override fun makeMessagingService(database: CordaPersistence, info: NodeInfo): MessagingService { - userService = RPCUserServiceImpl(configuration.rpcUsers) + // Construct security manager reading users data either from the 'security' config section + // if present or from rpcUsers list if the former is missing from config. + val securityManagerConfig = configuration.security?.authService ?: + SecurityConfiguration.AuthService.fromUsers(configuration.rpcUsers) + + securityManager = RPCSecurityManagerImpl(securityManagerConfig) val serverAddress = configuration.messagingServerAddress ?: makeLocalMessageBroker() val advertisedAddress = info.addresses.single() @@ -156,7 +161,7 @@ open class Node(configuration: NodeConfiguration, private fun makeLocalMessageBroker(): NetworkHostAndPort { with(configuration) { - messageBroker = ArtemisMessagingServer(this, p2pAddress.port, rpcAddress?.port, services.networkMapCache, userService) + messageBroker = ArtemisMessagingServer(this, p2pAddress.port, rpcAddress?.port, services.networkMapCache, securityManager) return NetworkHostAndPort("localhost", p2pAddress.port) } } @@ -208,7 +213,7 @@ open class Node(configuration: NodeConfiguration, // Start up the MQ clients. rpcMessagingClient.run { runOnStop += this::stop - start(rpcOps, userService) + start(rpcOps, securityManager) } verifierMessagingClient?.run { runOnStop += this::stop @@ -221,10 +226,10 @@ open class Node(configuration: NodeConfiguration, } /** - * If the node is persisting to an embedded H2 database, then expose this via TCP with a JDBC URL of the form: + * If the node is persisting to an embedded H2 database, then expose this via TCP with a DB URL of the form: * jdbc:h2:tcp://:/node * with username and password as per the DataSource connection details. The key element to enabling this support is to - * ensure that you specify a JDBC connection URL of the form jdbc:h2:file: in the node config and that you include + * ensure that you specify a DB connection URL of the form jdbc:h2:file: in the node config and that you include * the H2 option AUTO_SERVER_PORT set to the port you desire to use (0 will give a dynamically allocated port number) * but exclude the H2 option AUTO_SERVER=TRUE. * This is not using the H2 "automatic mixed mode" directly but leans on many of the underpinnings. For more details diff --git a/node/src/main/kotlin/net/corda/node/internal/RpcAuthorisationProxy.kt b/node/src/main/kotlin/net/corda/node/internal/RpcAuthorisationProxy.kt index 7a1e66f6ea..d2f0f8afc1 100644 --- a/node/src/main/kotlin/net/corda/node/internal/RpcAuthorisationProxy.kt +++ b/node/src/main/kotlin/net/corda/node/internal/RpcAuthorisationProxy.kt @@ -1,5 +1,6 @@ package net.corda.node.internal +import net.corda.client.rpc.PermissionException import net.corda.core.contracts.ContractState import net.corda.core.crypto.SecureHash import net.corda.core.flows.FlowLogic @@ -156,9 +157,12 @@ class RpcAuthorisationProxy(private val implementation: CordaRPCOps, private val private inline fun guard(methodName: String, action: () -> RESULT) = guard(methodName, emptyList(), action) // TODO change to KFunction reference after Kotlin fixes https://youtrack.jetbrains.com/issue/KT-12140 - private inline fun guard(methodName: String, args: List, action: () -> RESULT): RESULT { - - context().requireEitherPermission(permissionsAllowing.invoke(methodName, args)) - return action() + private inline fun guard(methodName: String, args: List>, action: () -> RESULT) : RESULT { + if (!context().isPermitted(methodName, *(args.map { it.name }.toTypedArray()))) { + throw PermissionException("User not authorized to perform RPC call $methodName with target $args") + } + else { + return action() + } } } \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/internal/security/AuthorizingSubject.kt b/node/src/main/kotlin/net/corda/node/internal/security/AuthorizingSubject.kt new file mode 100644 index 0000000000..d831582747 --- /dev/null +++ b/node/src/main/kotlin/net/corda/node/internal/security/AuthorizingSubject.kt @@ -0,0 +1,28 @@ +package net.corda.node.internal.security + +/** + * Provides permission checking for the subject identified by the given [principal]. + */ +interface AuthorizingSubject { + + /** + * Identity of underlying subject + */ + val principal: String + + /** + * Determines if the underlying subject is entitled to perform a certain action, + * (e.g. an RPC invocation) represented by an [action] string followed by an + * optional list of arguments. + */ + fun isPermitted(action : String, vararg arguments : String) : Boolean +} + +/** + * An implementation of [AuthorizingSubject] permitting all actions + */ +class AdminSubject(override val principal : String) : AuthorizingSubject { + + override fun isPermitted(action: String, vararg arguments: String) = true + +} \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/internal/security/Password.kt b/node/src/main/kotlin/net/corda/node/internal/security/Password.kt new file mode 100644 index 0000000000..105addc8c1 --- /dev/null +++ b/node/src/main/kotlin/net/corda/node/internal/security/Password.kt @@ -0,0 +1,43 @@ +package net.corda.node.internal.security + +import java.util.* + +class Password(valueRaw: CharArray) : AutoCloseable { + + constructor(value: String) : this(value.toCharArray()) + + private val internalValue = valueRaw.copyOf() + + val value: CharArray + get() = internalValue.copyOf() + + val valueAsString: String + get() = internalValue.joinToString("") + + override fun close() { + internalValue.indices.forEach { index -> + internalValue[index] = MASK + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Password + + if (!Arrays.equals(internalValue, other.internalValue)) return false + + return true + } + + override fun hashCode(): Int { + return Arrays.hashCode(internalValue) + } + + override fun toString(): String = (0..5).map { MASK }.joinToString("") + + private companion object { + private const val MASK = '*' + } +} \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/internal/security/RPCSecurityManager.kt b/node/src/main/kotlin/net/corda/node/internal/security/RPCSecurityManager.kt new file mode 100644 index 0000000000..dafa069833 --- /dev/null +++ b/node/src/main/kotlin/net/corda/node/internal/security/RPCSecurityManager.kt @@ -0,0 +1,41 @@ +package net.corda.node.internal.security + +import net.corda.core.context.AuthServiceId +import org.apache.shiro.authc.AuthenticationException +import javax.security.auth.login.FailedLoginException + +/** + * Manage security of RPC users, providing logic for user authentication and authorization. + */ +interface RPCSecurityManager : AutoCloseable { + /** + * An identifier associated to this security service + */ + val id: AuthServiceId + + /** + * Perform user authentication from principal and password. Return an [AuthorizingSubject] containing + * the permissions of the user identified by the given [principal] if authentication via password succeeds, + * otherwise a [FailedLoginException] is thrown. + */ + fun authenticate(principal: String, password: Password): AuthorizingSubject + + /** + * Construct an [AuthorizingSubject] instance con permissions of the user associated to + * the given principal. Throws an exception if the principal cannot be resolved to a known user. + */ + fun buildSubject(principal: String): AuthorizingSubject +} + +/** + * Non-throwing version of authenticate, returning null instead of throwing in case of authentication failure + */ +fun RPCSecurityManager.tryAuthenticate(principal: String, password: Password): AuthorizingSubject? { + password.use { + return try { + authenticate(principal, password) + } catch (e: AuthenticationException) { + null + } + } +} diff --git a/node/src/main/kotlin/net/corda/node/internal/security/RPCSecurityManagerImpl.kt b/node/src/main/kotlin/net/corda/node/internal/security/RPCSecurityManagerImpl.kt new file mode 100644 index 0000000000..add690620e --- /dev/null +++ b/node/src/main/kotlin/net/corda/node/internal/security/RPCSecurityManagerImpl.kt @@ -0,0 +1,308 @@ +package net.corda.node.internal.security + +import com.google.common.cache.CacheBuilder +import com.google.common.cache.Cache +import com.google.common.primitives.Ints +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import net.corda.core.context.AuthServiceId +import net.corda.core.utilities.loggerFor +import net.corda.node.services.config.PasswordEncryption +import net.corda.node.services.config.SecurityConfiguration +import net.corda.node.services.config.AuthDataSourceType +import net.corda.nodeapi.internal.config.User +import org.apache.shiro.authc.* +import org.apache.shiro.authc.credential.PasswordMatcher +import org.apache.shiro.authc.credential.SimpleCredentialsMatcher +import org.apache.shiro.authz.AuthorizationInfo +import org.apache.shiro.authz.Permission +import org.apache.shiro.authz.SimpleAuthorizationInfo +import org.apache.shiro.authz.permission.DomainPermission +import org.apache.shiro.authz.permission.PermissionResolver +import org.apache.shiro.cache.CacheManager +import org.apache.shiro.mgt.DefaultSecurityManager +import org.apache.shiro.realm.AuthorizingRealm +import org.apache.shiro.realm.jdbc.JdbcRealm +import org.apache.shiro.subject.PrincipalCollection +import org.apache.shiro.subject.SimplePrincipalCollection +import javax.security.auth.login.FailedLoginException +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +private typealias AuthServiceConfig = SecurityConfiguration.AuthService + +/** + * Default implementation of [RPCSecurityManager] adapting + * [org.apache.shiro.mgt.SecurityManager] + */ +class RPCSecurityManagerImpl(config: AuthServiceConfig) : RPCSecurityManager { + + override val id = config.id + private val manager: DefaultSecurityManager + + init { + manager = buildImpl(config) + } + + override fun close() { + manager.destroy() + } + + @Throws(FailedLoginException::class) + override fun authenticate(principal: String, password: Password): AuthorizingSubject { + password.use { + val authToken = UsernamePasswordToken(principal, it.value) + try { + manager.authenticate(authToken) + } catch (authcException: AuthenticationException) { + throw FailedLoginException(authcException.toString()) + } + return ShiroAuthorizingSubject( + subjectId = SimplePrincipalCollection(principal, id.value), + manager = manager) + } + } + + override fun buildSubject(principal: String): AuthorizingSubject = + ShiroAuthorizingSubject( + subjectId = SimplePrincipalCollection(principal, id.value), + manager = manager) + + + companion object { + + private val logger = loggerFor() + + /** + * Instantiate RPCSecurityManager initialised with users data from a list of [User] + */ + fun fromUserList(id: AuthServiceId, users: List) = + RPCSecurityManagerImpl( + AuthServiceConfig.fromUsers(users).copy(id = id)) + + // Build internal Shiro securityManager instance + private fun buildImpl(config: AuthServiceConfig): DefaultSecurityManager { + val realm = when (config.dataSource.type) { + AuthDataSourceType.DB -> { + logger.info("Constructing DB-backed security data source: ${config.dataSource.connection}") + NodeJdbcRealm(config.dataSource) + } + AuthDataSourceType.INMEMORY -> { + logger.info("Constructing realm from list of users in config ${config.dataSource.users!!}") + InMemoryRealm(config.dataSource.users, config.id.value, config.dataSource.passwordEncryption) + } + } + return DefaultSecurityManager(realm).also { + // Setup optional cache layer if configured + it.cacheManager = config.options?.cache?.let { + GuavaCacheManager( + timeToLiveSeconds = it.expiryTimeInSecs, + maxSize = it.capacity) + } + } + } + } +} + +/** + * Provide a representation of RPC permissions based on Apache Shiro permissions framework. + * A permission represents a set of actions: for example, the set of all RPC invocations, or the set + * of RPC invocations acting on a given class of Flows in input. A permission `implies` another one if + * its set of actions contains the set of actions in the other one. In Apache Shiro, permissions are + * represented by instances of the [Permission] interface which offers a single method: [implies], to + * test if the 'x implies y' binary predicate is satisfied. + */ +private class RPCPermission : DomainPermission { + + /** + * Helper constructor directly setting actions and target field + * + * @param methods Set of allowed RPC methods + * @param target An optional "target" type on which methods act + */ + constructor(methods: Set, target: String? = null) : super(methods, target?.let { setOf(it) }) + + + /** + * Default constructor instantiate an "ALL" permission + */ + constructor() : super() +} + +/** + * A [org.apache.shiro.authz.permission.PermissionResolver] implementation for RPC permissions. + * Provides a method to construct an [RPCPermission] instance from its string representation + * in the form used by a Node admin. + * + * Currently valid permission strings have the forms: + * + * - `ALL`: allowing all type of RPC calls + * + * - `InvokeRpc.$RPCMethodName`: allowing to call a given RPC method without restrictions on its arguments. + * + * - `StartFlow.$FlowClassName`: allowing to call a `startFlow*` RPC method targeting a Flow instance + * of a given class + * + */ +private object RPCPermissionResolver : PermissionResolver { + + private val SEPARATOR = '.' + private val ACTION_START_FLOW = "startflow" + private val ACTION_INVOKE_RPC = "invokerpc" + private val ACTION_ALL = "all" + + private val FLOW_RPC_CALLS = setOf("startFlowDynamic", "startTrackedFlowDynamic") + + override fun resolvePermission(representation: String): Permission { + + val action = representation.substringBefore(SEPARATOR).toLowerCase() + when (action) { + ACTION_INVOKE_RPC -> { + val rpcCall = representation.substringAfter(SEPARATOR) + require(representation.count { it == SEPARATOR } == 1) { + "Malformed permission string" + } + return RPCPermission(setOf(rpcCall)) + } + ACTION_START_FLOW -> { + val targetFlow = representation.substringAfter(SEPARATOR) + require(targetFlow.isNotEmpty()) { + "Missing target flow after StartFlow" + } + return RPCPermission(FLOW_RPC_CALLS, targetFlow) + } + ACTION_ALL -> { + // Leaving empty set of targets and actions to match everything + return RPCPermission() + } + else -> throw IllegalArgumentException("Unkwnow permission action specifier: $action") + } + } +} + +private class ShiroAuthorizingSubject( + private val subjectId: PrincipalCollection, + private val manager: DefaultSecurityManager) : AuthorizingSubject { + + override val principal get() = subjectId.primaryPrincipal.toString() + + override fun isPermitted(action: String, vararg arguments: String) = + manager.isPermitted(subjectId, RPCPermission(setOf(action), arguments.firstOrNull())) +} + +private fun buildCredentialMatcher(type: PasswordEncryption) = when (type) { + PasswordEncryption.NONE -> SimpleCredentialsMatcher() + PasswordEncryption.SHIRO_1_CRYPT -> PasswordMatcher() +} + +private class InMemoryRealm(users: List, + realmId: String, + passwordEncryption: PasswordEncryption = PasswordEncryption.NONE) : AuthorizingRealm() { + + private val authorizationInfoByUser: Map + private val authenticationInfoByUser: Map + + init { + permissionResolver = RPCPermissionResolver + users.forEach { + require(it.username.matches("\\w+".toRegex())) { + "Username ${it.username} contains invalid characters" + } + } + val resolvePermission = { s: String -> permissionResolver.resolvePermission(s) } + authorizationInfoByUser = users.associate { + it.username to SimpleAuthorizationInfo().apply { + objectPermissions = it.permissions.map { resolvePermission(it) }.toSet() + roles = emptySet() + stringPermissions = emptySet() + } + } + authenticationInfoByUser = users.associate { + it.username to SimpleAuthenticationInfo().apply { + credentials = it.password + principals = SimplePrincipalCollection(it.username, realmId) + } + } + credentialsMatcher = buildCredentialMatcher(passwordEncryption) + } + + // Methods from AuthorizingRealm interface used by Shiro to query + // for authentication/authorization data for a given user + override fun doGetAuthenticationInfo(token: AuthenticationToken) = + authenticationInfoByUser[token.principal as String] + + override fun doGetAuthorizationInfo(principals: PrincipalCollection) = + authorizationInfoByUser[principals.primaryPrincipal as String] +} + +private class NodeJdbcRealm(config: SecurityConfiguration.AuthService.DataSource) : JdbcRealm() { + + init { + credentialsMatcher = buildCredentialMatcher(config.passwordEncryption) + setPermissionsLookupEnabled(true) + dataSource = HikariDataSource(HikariConfig(config.connection!!)) + permissionResolver = RPCPermissionResolver + } +} + +private typealias ShiroCache = org.apache.shiro.cache.Cache + +/** + * Adapts a [com.google.common.cache.Cache] to a [org.apache.shiro.cache.Cache] implementation. + */ +private fun Cache.toShiroCache(name: String) = object : ShiroCache { + + val name = name + private val impl = this@toShiroCache + + override operator fun get(key: K) = impl.getIfPresent(key) + + override fun put(key: K, value: V): V? { + val lastValue = get(key) + impl.put(key, value) + return lastValue + } + + override fun remove(key: K): V? { + val lastValue = get(key) + impl.invalidate(key) + return lastValue + } + + override fun clear() { + impl.invalidateAll() + } + + override fun size() = Ints.checkedCast(impl.size()) + override fun keys() = impl.asMap().keys + override fun values() = impl.asMap().values + override fun toString() = "Guava cache adapter [$impl]" +} + +/** + * Implementation of [org.apache.shiro.cache.CacheManager] based on + * cache implementation in [com.google.common.cache] + */ +private class GuavaCacheManager(val maxSize: Long, + val timeToLiveSeconds: Long) : CacheManager { + + private val instances = ConcurrentHashMap>() + + override fun getCache(name: String): ShiroCache { + val result = instances[name] ?: buildCache(name) + instances.putIfAbsent(name, result) + return result as ShiroCache + } + + private fun buildCache(name: String) : ShiroCache { + logger.info("Constructing cache '$name' with maximumSize=$maxSize, TTL=${timeToLiveSeconds}s") + return CacheBuilder.newBuilder() + .expireAfterWrite(timeToLiveSeconds, TimeUnit.SECONDS) + .maximumSize(maxSize) + .build() + .toShiroCache(name) + } + + companion object { + private val logger = loggerFor() + } +} \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/services/RPCUserService.kt b/node/src/main/kotlin/net/corda/node/services/RPCUserService.kt deleted file mode 100644 index 3a7bbdcb9f..0000000000 --- a/node/src/main/kotlin/net/corda/node/services/RPCUserService.kt +++ /dev/null @@ -1,33 +0,0 @@ -package net.corda.node.services - -import net.corda.core.context.AuthServiceId -import net.corda.nodeapi.internal.config.User - -/** - * Service for retrieving [User] objects representing RPC users who are authorised to use the RPC system. A [User] - * contains their login username and password along with a set of permissions for RPC services they are allowed access - * to. These permissions are represented as [String]s to allow RPC implementations to add their own permissioning. - */ -interface RPCUserService { - - fun getUser(username: String): User? - val users: List - - val id: AuthServiceId -} - -// TODO Store passwords as salted hashes -// TODO Or ditch this and consider something like Apache Shiro -// TODO Need access to permission checks from inside flows and at other point during audit checking. -class RPCUserServiceImpl(override val users: List) : RPCUserService { - - override val id: AuthServiceId = AuthServiceId("NODE_FILE_CONFIGURATION") - - init { - users.forEach { - require(it.username.matches("\\w+".toRegex())) { "Username ${it.username} contains invalid characters" } - } - } - - override fun getUser(username: String): User? = users.find { it.username == username } -} diff --git a/node/src/main/kotlin/net/corda/node/services/config/NodeConfiguration.kt b/node/src/main/kotlin/net/corda/node/services/config/NodeConfiguration.kt index 33ccd2bf0f..56dbcdfe7a 100644 --- a/node/src/main/kotlin/net/corda/node/services/config/NodeConfiguration.kt +++ b/node/src/main/kotlin/net/corda/node/services/config/NodeConfiguration.kt @@ -1,6 +1,7 @@ package net.corda.node.services.config import com.typesafe.config.Config +import net.corda.core.context.AuthServiceId import net.corda.core.identity.CordaX500Name import net.corda.core.utilities.NetworkHostAndPort import net.corda.core.utilities.seconds @@ -21,6 +22,7 @@ interface NodeConfiguration : NodeSSLConfiguration { val exportJMXto: String val dataSourceProperties: Properties val rpcUsers: List + val security: SecurityConfiguration? val devMode: Boolean val devModeOptions: DevModeOptions? val compatibilityZoneURL: URL? @@ -93,6 +95,7 @@ data class NodeConfigurationImpl( override val dataSourceProperties: Properties, override val compatibilityZoneURL: URL? = null, override val rpcUsers: List, + override val security : SecurityConfiguration? = null, override val verifierType: VerifierType, // TODO typesafe config supports the notion of durations. Make use of that by mapping it to java.time.Duration. // Then rename this to messageRedeliveryDelay and make it of type Duration @@ -115,6 +118,7 @@ data class NodeConfigurationImpl( override val sshd: SSHDConfiguration? = null, override val database: DatabaseConfig = DatabaseConfig(initialiseSchema = devMode, exportHibernateJMXStatistics = devMode) ) : NodeConfiguration { + override val exportJMXto: String get() = "http" init { @@ -122,6 +126,9 @@ data class NodeConfigurationImpl( require(!useTestClock || devMode) { "Cannot use test clock outside of dev mode" } require(devModeOptions == null || devMode) { "Cannot use devModeOptions outside of dev mode" } require(myLegalName.commonName == null) { "Common name must be null: $myLegalName" } + require(security == null || rpcUsers.isEmpty()) { + "Cannot specify both 'rpcUsers' and 'security' in configuration" + } } } @@ -149,4 +156,78 @@ data class CertChainPolicyConfig(val role: String, private val policy: CertChain } } -data class SSHDConfiguration(val port: Int) \ No newline at end of file +data class SSHDConfiguration(val port: Int) + +// Supported types of authentication/authorization data providers +enum class AuthDataSourceType { + // External RDBMS + DB, + + // Static dataset hard-coded in config + INMEMORY +} + +// Password encryption scheme +enum class PasswordEncryption { + + // Password stored in clear + NONE, + + // Password salt-hashed using Apache Shiro flexible encryption format + // [org.apache.shiro.crypto.hash.format.Shiro1CryptFormat] + SHIRO_1_CRYPT +} + +// Subset of Node configuration related to security aspects +data class SecurityConfiguration(val authService: SecurityConfiguration.AuthService) { + + // Configure RPC/Shell users authentication/authorization service + data class AuthService(val dataSource: AuthService.DataSource, + val id: AuthServiceId = defaultAuthServiceId(dataSource.type), + val options: AuthService.Options? = null) { + + init { + require(!(dataSource.type == AuthDataSourceType.INMEMORY && + options?.cache != null)) { + "No cache supported for INMEMORY data provider" + } + } + + // Optional components: cache + data class Options(val cache: Options.Cache?) { + + // Cache parameters + data class Cache(val expiryTimeInSecs: Long, val capacity: Long) + + } + + // Provider of users credentials and permissions data + data class DataSource(val type: AuthDataSourceType, + val passwordEncryption: PasswordEncryption = PasswordEncryption.NONE, + val connection: Properties? = null, + val users: List? = null) { + init { + when (type) { + AuthDataSourceType.INMEMORY -> require(users != null && connection == null) + AuthDataSourceType.DB -> require(users == null && connection != null) + } + } + } + + companion object { + // If unspecified, we assign an AuthServiceId by default based on the + // underlying data provider + fun defaultAuthServiceId(type: AuthDataSourceType) = when (type) { + AuthDataSourceType.INMEMORY -> AuthServiceId("NODE_CONFIG") + AuthDataSourceType.DB -> AuthServiceId("REMOTE_DATABASE") + } + + fun fromUsers(users: List) = AuthService( + dataSource = DataSource( + type = AuthDataSourceType.INMEMORY, + users = users, + passwordEncryption = PasswordEncryption.NONE), + id = AuthServiceId("NODE_CONFIG")) + } + } +} \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/services/messaging/ArtemisMessagingServer.kt b/node/src/main/kotlin/net/corda/node/services/messaging/ArtemisMessagingServer.kt index 3506fbef9e..5634ca74ed 100644 --- a/node/src/main/kotlin/net/corda/node/services/messaging/ArtemisMessagingServer.kt +++ b/node/src/main/kotlin/net/corda/node/services/messaging/ArtemisMessagingServer.kt @@ -12,9 +12,13 @@ import net.corda.core.node.NodeInfo import net.corda.core.node.services.NetworkMapCache import net.corda.core.node.services.NetworkMapCache.MapChange import net.corda.core.serialization.SingletonSerializeAsToken -import net.corda.core.utilities.* +import net.corda.core.utilities.NetworkHostAndPort +import net.corda.core.utilities.contextLogger +import net.corda.core.utilities.debug +import net.corda.core.utilities.parsePublicKeyBase58 import net.corda.node.internal.Node -import net.corda.node.services.RPCUserService +import net.corda.node.internal.security.Password +import net.corda.node.internal.security.RPCSecurityManager import net.corda.node.services.config.NodeConfiguration import net.corda.node.services.messaging.NodeLoginModule.Companion.NODE_ROLE import net.corda.node.services.messaging.NodeLoginModule.Companion.PEER_ROLE @@ -25,13 +29,13 @@ import net.corda.nodeapi.internal.crypto.X509Utilities.CORDA_CLIENT_TLS import net.corda.nodeapi.internal.crypto.X509Utilities.CORDA_ROOT_CA import net.corda.nodeapi.internal.crypto.loadKeyStore import net.corda.nodeapi.* +import net.corda.nodeapi.internal.ArtemisMessagingComponent.ArtemisPeerAddress import net.corda.nodeapi.internal.ArtemisMessagingComponent.Companion.INTERNAL_PREFIX import net.corda.nodeapi.internal.ArtemisMessagingComponent.Companion.NODE_USER import net.corda.nodeapi.internal.ArtemisMessagingComponent.Companion.NOTIFICATIONS_ADDRESS import net.corda.nodeapi.internal.ArtemisMessagingComponent.Companion.P2P_QUEUE import net.corda.nodeapi.internal.ArtemisMessagingComponent.Companion.PEERS_PREFIX import net.corda.nodeapi.internal.ArtemisMessagingComponent.Companion.PEER_USER -import net.corda.nodeapi.internal.ArtemisMessagingComponent.ArtemisPeerAddress import net.corda.nodeapi.internal.ArtemisMessagingComponent.NodeAddress import net.corda.nodeapi.internal.requireOnDefaultFileSystem import org.apache.activemq.artemis.api.core.SimpleString @@ -97,7 +101,7 @@ class ArtemisMessagingServer(private val config: NodeConfiguration, private val p2pPort: Int, val rpcPort: Int?, val networkMapCache: NetworkMapCache, - val userService: RPCUserService) : SingletonSerializeAsToken() { + val securityManager: RPCSecurityManager) : SingletonSerializeAsToken() { companion object { private val log = contextLogger() /** 10 MiB maximum allowed file size for attachments, including message headers. TODO: acquire this value from Network Map when supported. */ @@ -234,7 +238,7 @@ class ArtemisMessagingServer(private val config: NodeConfiguration, * 3. RPC users. These are only given sufficient access to perform RPC with us. * 4. Verifiers. These are given read access to the verification request queue and write access to the response queue. */ - private fun ConfigurationImpl.configureAddressSecurity() : Pair { + private fun ConfigurationImpl.configureAddressSecurity(): Pair { val nodeInternalRole = Role(NODE_ROLE, true, true, true, true, true, true, true, true) securityRoles["$INTERNAL_PREFIX#"] = setOf(nodeInternalRole) // Do not add any other roles here as it's only for the node securityRoles[P2P_QUEUE] = setOf(nodeInternalRole, restrictedRole(PEER_ROLE, send = true)) @@ -285,7 +289,7 @@ class ArtemisMessagingServer(private val config: NodeConfiguration, override fun getAppConfigurationEntry(name: String): Array { val options = mapOf( LoginListener::javaClass.name to loginListener, - RPCUserService::class.java.name to userService, + RPCSecurityManager::class.java.name to securityManager, NodeLoginModule.CERT_CHAIN_CHECKS_OPTION_NAME to certChecks) return arrayOf(AppConfigurationEntry(name, REQUIRED, options)) } @@ -560,7 +564,7 @@ class NodeLoginModule : LoginModule { private var loginSucceeded: Boolean = false private lateinit var subject: Subject private lateinit var callbackHandler: CallbackHandler - private lateinit var userService: RPCUserService + private lateinit var securityManager: RPCSecurityManager private lateinit var loginListener: LoginListener private lateinit var peerCertCheck: CertificateChainCheckPolicy.Check private lateinit var nodeCertCheck: CertificateChainCheckPolicy.Check @@ -570,7 +574,7 @@ class NodeLoginModule : LoginModule { override fun initialize(subject: Subject, callbackHandler: CallbackHandler, sharedState: Map, options: Map) { this.subject = subject this.callbackHandler = callbackHandler - userService = options[RPCUserService::class.java.name] as RPCUserService + securityManager = options[RPCSecurityManager::class.java.name] as RPCSecurityManager loginListener = options[LoginListener::javaClass.name] as LoginListener val certChainChecks: Map = uncheckedCast(options[CERT_CHAIN_CHECKS_OPTION_NAME]) peerCertCheck = certChainChecks[PEER_ROLE]!! @@ -601,7 +605,7 @@ class NodeLoginModule : LoginModule { PEER_ROLE -> authenticatePeer(certificates) NODE_ROLE -> authenticateNode(certificates) VERIFIER_ROLE -> authenticateVerifier(certificates) - RPC_ROLE -> authenticateRpcUser(password, username) + RPC_ROLE -> authenticateRpcUser(username, Password(password)) else -> throw FailedLoginException("Peer does not belong on our network") } principals += UserPrincipal(validatedUser) @@ -632,13 +636,8 @@ class NodeLoginModule : LoginModule { return certificates.first().subjectDN.name } - private fun authenticateRpcUser(password: String, username: String): String { - val rpcUser = userService.getUser(username) ?: throw FailedLoginException("User does not exist") - if (password != rpcUser.password) { - // TODO Switch to hashed passwords - // TODO Retrieve client IP address to include in exception message - throw FailedLoginException("Password for user $username does not match") - } + private fun authenticateRpcUser(username: String, password: Password): String { + securityManager.authenticate(username, password) loginListener(username) principals += RolePrincipal(RPC_ROLE) // This enables the RPC client to send requests principals += RolePrincipal("${RPCApi.RPC_CLIENT_QUEUE_NAME_PREFIX}.$username") // This enables the RPC client to receive responses diff --git a/node/src/main/kotlin/net/corda/node/services/messaging/RPCMessagingClient.kt b/node/src/main/kotlin/net/corda/node/services/messaging/RPCMessagingClient.kt index 4fd197c307..b45ad4adcc 100644 --- a/node/src/main/kotlin/net/corda/node/services/messaging/RPCMessagingClient.kt +++ b/node/src/main/kotlin/net/corda/node/services/messaging/RPCMessagingClient.kt @@ -4,7 +4,7 @@ import net.corda.core.identity.CordaX500Name import net.corda.core.messaging.RPCOps import net.corda.core.serialization.SingletonSerializeAsToken import net.corda.core.utilities.NetworkHostAndPort -import net.corda.node.services.RPCUserService +import net.corda.node.internal.security.RPCSecurityManager import net.corda.nodeapi.internal.config.SSLConfiguration import net.corda.nodeapi.internal.ArtemisMessagingComponent.Companion.NODE_USER import net.corda.nodeapi.internal.crypto.X509Utilities @@ -16,10 +16,10 @@ class RPCMessagingClient(private val config: SSLConfiguration, serverAddress: Ne private val artemis = ArtemisMessagingClient(config, serverAddress) private var rpcServer: RPCServer? = null - fun start(rpcOps: RPCOps, userService: RPCUserService) = synchronized(this) { + fun start(rpcOps: RPCOps, securityManager: RPCSecurityManager) = synchronized(this) { val locator = artemis.start().sessionFactory.serverLocator val myCert = loadKeyStore(config.sslKeystore, config.keyStorePassword).getX509Certificate(X509Utilities.CORDA_CLIENT_TLS) - rpcServer = RPCServer(rpcOps, NODE_USER, NODE_USER, locator, userService, CordaX500Name.build(myCert.subjectX500Principal)) + rpcServer = RPCServer(rpcOps, NODE_USER, NODE_USER, locator, securityManager, CordaX500Name.build(myCert.subjectX500Principal)) } fun start2(serverControl: ActiveMQServerControl) = synchronized(this) { diff --git a/node/src/main/kotlin/net/corda/node/services/messaging/RPCServer.kt b/node/src/main/kotlin/net/corda/node/services/messaging/RPCServer.kt index 1bcf4f5de2..0e06674aa0 100644 --- a/node/src/main/kotlin/net/corda/node/services/messaging/RPCServer.kt +++ b/node/src/main/kotlin/net/corda/node/services/messaging/RPCServer.kt @@ -26,11 +26,10 @@ import net.corda.core.serialization.SerializationContext import net.corda.core.serialization.SerializationDefaults.RPC_SERVER_CONTEXT import net.corda.core.serialization.deserialize import net.corda.core.utilities.* -import net.corda.node.services.RPCUserService +import net.corda.node.internal.security.AuthorizingSubject +import net.corda.node.internal.security.RPCSecurityManager import net.corda.node.services.logging.pushToLoggingContext import net.corda.nodeapi.* -import net.corda.nodeapi.internal.ArtemisMessagingComponent.Companion.NODE_USER -import net.corda.nodeapi.internal.config.User import org.apache.activemq.artemis.api.core.Message import org.apache.activemq.artemis.api.core.SimpleString import org.apache.activemq.artemis.api.core.client.ActiveMQClient.DEFAULT_ACK_BATCH_SIZE @@ -85,7 +84,7 @@ class RPCServer( private val rpcServerUsername: String, private val rpcServerPassword: String, private val serverLocator: ServerLocator, - private val userService: RPCUserService, + private val securityManager: RPCSecurityManager, private val nodeLegalName: CordaX500Name, private val rpcConfiguration: RPCServerConfiguration = RPCServerConfiguration.default ) { @@ -213,6 +212,7 @@ class RPCServer( reaperScheduledFuture?.cancel(false) rpcExecutor?.shutdownNow() reaperExecutor?.shutdownNow() + securityManager.close() sessionAndConsumers.forEach { it.sessionFactory.close() } @@ -365,15 +365,10 @@ class RPCServer( return RpcAuthContext(InvocationContext.rpc(rpcActor.first, trace, externalTrace, impersonatedActor), rpcActor.second) } - private fun actorFrom(message: ClientMessage): Pair { + private fun actorFrom(message: ClientMessage): Pair { val validatedUser = message.getStringProperty(Message.HDR_VALIDATED_USER) ?: throw IllegalArgumentException("Missing validated user from the Artemis message") val targetLegalIdentity = message.getStringProperty(RPCApi.RPC_TARGET_LEGAL_IDENTITY)?.let(CordaX500Name.Companion::parse) ?: nodeLegalName - // TODO switch userService based on targetLegalIdentity - val rpcUser = userService.getUser(validatedUser) ?: - throw IllegalArgumentException("Validated user '$validatedUser' is not an RPC user") - return Pair( - Actor(Id(rpcUser.username), userService.id, targetLegalIdentity), - RpcPermissions(rpcUser.permissions)) + return Pair(Actor(Id(validatedUser), securityManager.id, targetLegalIdentity), securityManager.buildSubject(validatedUser)) } } diff --git a/node/src/main/kotlin/net/corda/node/services/messaging/RpcAuthContext.kt b/node/src/main/kotlin/net/corda/node/services/messaging/RpcAuthContext.kt index 58cd73de22..dfa50b16d7 100644 --- a/node/src/main/kotlin/net/corda/node/services/messaging/RpcAuthContext.kt +++ b/node/src/main/kotlin/net/corda/node/services/messaging/RpcAuthContext.kt @@ -1,30 +1,9 @@ package net.corda.node.services.messaging -import net.corda.client.rpc.PermissionException import net.corda.core.context.InvocationContext -import net.corda.node.services.Permissions -import net.corda.nodeapi.internal.ArtemisMessagingComponent +import net.corda.node.internal.security.AuthorizingSubject -data class RpcAuthContext(val invocation: InvocationContext, val grantedPermissions: RpcPermissions) { +data class RpcAuthContext(val invocation: InvocationContext, + private val authorizer: AuthorizingSubject) + : AuthorizingSubject by authorizer - fun requirePermission(permission: String) = requireEitherPermission(setOf(permission)) - - fun requireEitherPermission(permissions: Set): RpcAuthContext { - - // TODO remove the NODE_USER condition once webserver and shell won't need it anymore - if (invocation.principal().name != ArtemisMessagingComponent.NODE_USER && !grantedPermissions.coverAny(permissions)) { - throw PermissionException("User not permissioned with any of $permissions, permissions are ${this.grantedPermissions}.") - } - return this - } -} - -data class RpcPermissions(private val values: Set = emptySet()) { - - companion object { - val NONE = RpcPermissions() - val ALL = RpcPermissions(setOf("ALL")) - } - - fun coverAny(permissions: Set) = !values.intersect(permissions + Permissions.all()).isEmpty() -} \ No newline at end of file diff --git a/node/src/main/kotlin/net/corda/node/shell/CordaAuthenticationPlugin.kt b/node/src/main/kotlin/net/corda/node/shell/CordaAuthenticationPlugin.kt index 61fbf90f56..7dbdc8e52f 100644 --- a/node/src/main/kotlin/net/corda/node/shell/CordaAuthenticationPlugin.kt +++ b/node/src/main/kotlin/net/corda/node/shell/CordaAuthenticationPlugin.kt @@ -4,31 +4,30 @@ import net.corda.core.context.Actor import net.corda.core.context.InvocationContext import net.corda.core.identity.CordaX500Name import net.corda.core.messaging.CordaRPCOps -import net.corda.node.services.RPCUserService -import net.corda.node.services.messaging.RpcPermissions +import net.corda.node.internal.security.Password +import net.corda.node.internal.security.RPCSecurityManager +import net.corda.node.internal.security.tryAuthenticate import org.crsh.auth.AuthInfo import org.crsh.auth.AuthenticationPlugin import org.crsh.plugin.CRaSHPlugin -class CordaAuthenticationPlugin(val rpcOps:CordaRPCOps, val userService:RPCUserService, val nodeLegalName:CordaX500Name) : CRaSHPlugin>(), AuthenticationPlugin { +class CordaAuthenticationPlugin(private val rpcOps: CordaRPCOps, private val securityManager: RPCSecurityManager, private val nodeLegalName: CordaX500Name) : CRaSHPlugin>(), AuthenticationPlugin { override fun getImplementation(): AuthenticationPlugin = this override fun getName(): String = "corda" override fun authenticate(username: String?, credential: String?): AuthInfo { + if (username == null || credential == null) { return AuthInfo.UNSUCCESSFUL } - - val user = userService.getUser(username) - - if (user != null && user.password == credential) { - val actor = Actor(Actor.Id(username), userService.id, nodeLegalName) - return CordaSSHAuthInfo(true, makeRPCOpsWithContext(rpcOps, InvocationContext.rpc(actor), RpcPermissions(user.permissions))) + val authorizingSubject = securityManager.tryAuthenticate(username, Password(credential)) + if (authorizingSubject != null) { + val actor = Actor(Actor.Id(username), securityManager.id, nodeLegalName) + return CordaSSHAuthInfo(true, makeRPCOpsWithContext(rpcOps, InvocationContext.rpc(actor), authorizingSubject)) } - - return AuthInfo.UNSUCCESSFUL; + return AuthInfo.UNSUCCESSFUL } override fun getCredentialType(): Class = String::class.java diff --git a/node/src/main/kotlin/net/corda/node/shell/InteractiveShell.kt b/node/src/main/kotlin/net/corda/node/shell/InteractiveShell.kt index 3772e15290..ea6779f983 100644 --- a/node/src/main/kotlin/net/corda/node/shell/InteractiveShell.kt +++ b/node/src/main/kotlin/net/corda/node/shell/InteractiveShell.kt @@ -25,11 +25,11 @@ import net.corda.core.messaging.StateMachineUpdate import net.corda.core.node.services.IdentityService import net.corda.node.internal.Node import net.corda.node.internal.StartedNode -import net.corda.node.services.RPCUserService +import net.corda.node.internal.security.AdminSubject +import net.corda.node.internal.security.RPCSecurityManager import net.corda.node.services.config.NodeConfiguration import net.corda.node.services.messaging.CURRENT_RPC_CONTEXT import net.corda.node.services.messaging.RpcAuthContext -import net.corda.node.services.messaging.RpcPermissions import net.corda.node.utilities.ANSIProgressRenderer import net.corda.node.utilities.StdoutANSIProgressRenderer import net.corda.nodeapi.internal.persistence.CordaPersistence @@ -82,19 +82,19 @@ object InteractiveShell { private lateinit var node: StartedNode @VisibleForTesting internal lateinit var database: CordaPersistence - private lateinit var rpcOps:CordaRPCOps - private lateinit var userService:RPCUserService - private lateinit var identityService:IdentityService - private var shell:Shell? = null + private lateinit var rpcOps: CordaRPCOps + private lateinit var securityManager: RPCSecurityManager + private lateinit var identityService: IdentityService + private var shell: Shell? = null private lateinit var nodeLegalName: CordaX500Name /** * Starts an interactive shell connected to the local terminal. This shell gives administrator access to the node * internals. */ - fun startShell(configuration:NodeConfiguration, cordaRPCOps: CordaRPCOps, userService: RPCUserService, identityService: IdentityService, database: CordaPersistence) { + fun startShell(configuration: NodeConfiguration, cordaRPCOps: CordaRPCOps, securityManager: RPCSecurityManager, identityService: IdentityService, database: CordaPersistence) { this.rpcOps = cordaRPCOps - this.userService = userService + this.securityManager = securityManager this.identityService = identityService this.nodeLegalName = configuration.myLegalName this.database = database @@ -123,14 +123,14 @@ object InteractiveShell { } } - fun runLocalShell(node:StartedNode) { + fun runLocalShell(node: StartedNode) { val terminal = TerminalFactory.create() val consoleReader = ConsoleReader("Corda", FileInputStream(FileDescriptor.`in`), System.out, terminal) val jlineProcessor = JLineProcessor(terminal.isAnsiSupported, shell, consoleReader, System.out) InterruptHandler { jlineProcessor.interrupt() }.install() thread(name = "Command line shell processor", isDaemon = true) { // Give whoever has local shell access administrator access to the node. - val context = RpcAuthContext(net.corda.core.context.InvocationContext.shell(), RpcPermissions.ALL) + val context = RpcAuthContext(net.corda.core.context.InvocationContext.shell(), AdminSubject("SHELL_USER")) CURRENT_RPC_CONTEXT.set(context) Emoji.renderIfSupported { jlineProcessor.run() @@ -169,7 +169,7 @@ object InteractiveShell { // Don't use the Java language plugin (we may not have tools.jar available at runtime), this // will cause any commands using JIT Java compilation to be suppressed. In CRaSH upstream that // is only the 'jmx' command. - return super.getPlugins().filterNot { it is JavaLanguage } + CordaAuthenticationPlugin(rpcOps, userService, nodeLegalName) + return super.getPlugins().filterNot { it is JavaLanguage } + CordaAuthenticationPlugin(rpcOps, securityManager, nodeLegalName) } } val attributes = mapOf( @@ -180,7 +180,7 @@ object InteractiveShell { context.refresh() this.config = config start(context) - return context.getPlugin(ShellFactory::class.java).create(null, CordaSSHAuthInfo(false, makeRPCOpsWithContext(rpcOps, net.corda.core.context.InvocationContext.shell(), RpcPermissions.ALL), StdoutANSIProgressRenderer)) + return context.getPlugin(ShellFactory::class.java).create(null, CordaSSHAuthInfo(false, makeRPCOpsWithContext(rpcOps, net.corda.core.context.InvocationContext.shell(), AdminSubject("SHELL_USER")), StdoutANSIProgressRenderer)) } } @@ -248,7 +248,7 @@ object InteractiveShell { } catch (e: NoApplicableConstructor) { output.println("No matching constructor found:", Color.red) e.errors.forEach { output.println("- $it", Color.red) } - } catch (e:PermissionException) { + } catch (e: PermissionException) { output.println(e.message ?: "Access denied", Color.red) } finally { InputStreamDeserializer.closeAll() @@ -271,9 +271,9 @@ object InteractiveShell { */ @Throws(NoApplicableConstructor::class) fun runFlowFromString(invoke: (Class>, Array) -> FlowProgressHandle, - inputData: String, - clazz: Class>, - om: ObjectMapper = yamlInputMapper): FlowProgressHandle { + inputData: String, + clazz: Class>, + om: ObjectMapper = yamlInputMapper): FlowProgressHandle { // For each constructor, attempt to parse the input data as a method call. Use the first that succeeds, // and keep track of the reasons we failed so we can print them out if no constructors are usable. val parser = StringToMethodCallParser(clazz, om) diff --git a/node/src/main/kotlin/net/corda/node/shell/RPCOpsWithContext.kt b/node/src/main/kotlin/net/corda/node/shell/RPCOpsWithContext.kt index afb163fed0..01446bd58d 100644 --- a/node/src/main/kotlin/net/corda/node/shell/RPCOpsWithContext.kt +++ b/node/src/main/kotlin/net/corda/node/shell/RPCOpsWithContext.kt @@ -1,36 +1,39 @@ package net.corda.node.shell import net.corda.core.context.InvocationContext -import net.corda.core.messaging.* +import net.corda.core.messaging.CordaRPCOps import net.corda.core.utilities.getOrThrow +import net.corda.node.internal.security.AuthorizingSubject import net.corda.node.services.messaging.CURRENT_RPC_CONTEXT import net.corda.node.services.messaging.RpcAuthContext -import net.corda.node.services.messaging.RpcPermissions import java.lang.reflect.InvocationTargetException import java.lang.reflect.Proxy import java.util.concurrent.CompletableFuture import java.util.concurrent.Future -fun makeRPCOpsWithContext(cordaRPCOps: CordaRPCOps, invocationContext:InvocationContext, rpcPermissions: RpcPermissions) : CordaRPCOps { - return Proxy.newProxyInstance(CordaRPCOps::class.java.classLoader, arrayOf(CordaRPCOps::class.java), { proxy, method, args -> - RPCContextRunner(invocationContext, rpcPermissions) { - try { - method.invoke(cordaRPCOps, *(args ?: arrayOf())) - } catch (e: InvocationTargetException) { - // Unpack exception. - throw e.targetException - } - }.get().getOrThrow() - }) as CordaRPCOps +fun makeRPCOpsWithContext(cordaRPCOps: CordaRPCOps, invocationContext:InvocationContext, authorizingSubject: AuthorizingSubject) : CordaRPCOps { + + return Proxy.newProxyInstance(CordaRPCOps::class.java.classLoader, arrayOf(CordaRPCOps::class.java), { _, method, args -> + RPCContextRunner(invocationContext, authorizingSubject) { + try { + method.invoke(cordaRPCOps, *(args ?: arrayOf())) + } catch (e: InvocationTargetException) { + // Unpack exception. + throw e.targetException + } + }.get().getOrThrow() + }) as CordaRPCOps } -private class RPCContextRunner(val invocationContext:InvocationContext, val rpcPermissions: RpcPermissions, val block:() -> T) : Thread() { +private class RPCContextRunner(val invocationContext: InvocationContext, val authorizingSubject: AuthorizingSubject, val block:() -> T): Thread() { + private var result: CompletableFuture = CompletableFuture() + override fun run() { - CURRENT_RPC_CONTEXT.set(RpcAuthContext(invocationContext, rpcPermissions)) + CURRENT_RPC_CONTEXT.set(RpcAuthContext(invocationContext, authorizingSubject)) try { result.complete(block()) - } catch (e:Throwable) { + } catch (e: Throwable) { result.completeExceptionally(e) } finally { CURRENT_RPC_CONTEXT.remove() diff --git a/node/src/test/kotlin/net/corda/node/CordaRPCOpsImplTest.kt b/node/src/test/kotlin/net/corda/node/CordaRPCOpsImplTest.kt index 5ddff0c2d5..a579538eab 100644 --- a/node/src/test/kotlin/net/corda/node/CordaRPCOpsImplTest.kt +++ b/node/src/test/kotlin/net/corda/node/CordaRPCOpsImplTest.kt @@ -2,6 +2,7 @@ package net.corda.node import co.paralleluniverse.fibers.Suspendable import net.corda.client.rpc.PermissionException +import net.corda.core.context.AuthServiceId import net.corda.core.context.InvocationContext import net.corda.core.contracts.Amount import net.corda.core.contracts.ContractState @@ -26,11 +27,12 @@ import net.corda.finance.flows.CashIssueFlow import net.corda.finance.flows.CashPaymentFlow import net.corda.node.internal.SecureCordaRPCOps import net.corda.node.internal.StartedNode +import net.corda.node.internal.security.RPCSecurityManagerImpl import net.corda.node.services.Permissions.Companion.invokeRpc import net.corda.node.services.Permissions.Companion.startFlow import net.corda.node.services.messaging.CURRENT_RPC_CONTEXT import net.corda.node.services.messaging.RpcAuthContext -import net.corda.node.services.messaging.RpcPermissions +import net.corda.nodeapi.internal.config.User import net.corda.testing.* import net.corda.testing.node.MockNetwork import net.corda.testing.node.MockNetwork.MockNode @@ -48,6 +50,15 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +// Mock an AuthorizingSubject instance sticking to a fixed set of permissions +private fun buildSubject(principal: String, permissionStrings: Set) = + RPCSecurityManagerImpl.fromUserList( + id = AuthServiceId("TEST"), + users = listOf(User(username = principal, + password = "", + permissions = permissionStrings))) + .buildSubject(principal) + class CordaRPCOpsImplTest { private companion object { val testJar = "net/corda/node/testing/test.jar" @@ -67,7 +78,7 @@ class CordaRPCOpsImplTest { mockNet = MockNetwork(cordappPackages = listOf("net.corda.finance.contracts.asset")) aliceNode = mockNet.createNode(MockNodeParameters(legalName = ALICE_NAME)) rpc = SecureCordaRPCOps(aliceNode.services, aliceNode.smm, aliceNode.database, aliceNode.services) - CURRENT_RPC_CONTEXT.set(RpcAuthContext(InvocationContext.rpc(testActor()), RpcPermissions.NONE)) + CURRENT_RPC_CONTEXT.set(RpcAuthContext(InvocationContext.rpc(testActor()), buildSubject("TEST_USER", emptySet()))) mockNet.runNetwork() withPermissions(invokeRpc(CordaRPCOps::notaryIdentities)) { @@ -301,7 +312,8 @@ class CordaRPCOpsImplTest { val previous = CURRENT_RPC_CONTEXT.get() try { - CURRENT_RPC_CONTEXT.set(previous.copy(grantedPermissions = RpcPermissions(permissions.toSet()))) + CURRENT_RPC_CONTEXT.set(previous.copy(authorizer = + buildSubject(previous.principal, permissions.toSet()))) action.invoke() } finally { CURRENT_RPC_CONTEXT.set(previous) diff --git a/node/src/test/kotlin/net/corda/node/services/RPCUserServiceTest.kt b/node/src/test/kotlin/net/corda/node/services/RPCSecurityManagerTest.kt similarity index 68% rename from node/src/test/kotlin/net/corda/node/services/RPCUserServiceTest.kt rename to node/src/test/kotlin/net/corda/node/services/RPCSecurityManagerTest.kt index 0f54c85d1c..2866d41dc4 100644 --- a/node/src/test/kotlin/net/corda/node/services/RPCUserServiceTest.kt +++ b/node/src/test/kotlin/net/corda/node/services/RPCSecurityManagerTest.kt @@ -1,11 +1,12 @@ package net.corda.node.services - +import net.corda.core.context.AuthServiceId +import net.corda.node.internal.security.RPCSecurityManagerImpl import net.corda.nodeapi.internal.config.User import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Test -class RPCUserServiceTest { +class RPCSecurityManagerTest { @Test fun `Artemis special characters not permitted in RPC usernames`() { @@ -15,6 +16,6 @@ class RPCUserServiceTest { } private fun configWithRPCUsername(username: String) { - RPCUserServiceImpl(listOf(User(username, "password", setOf()))) + RPCSecurityManagerImpl.fromUserList(users = listOf(User(username, "password", setOf())), id = AuthServiceId("TEST")) } } \ No newline at end of file diff --git a/node/src/test/kotlin/net/corda/node/services/messaging/ArtemisMessagingTests.kt b/node/src/test/kotlin/net/corda/node/services/messaging/ArtemisMessagingTests.kt index 4b7972d02d..35d0284052 100644 --- a/node/src/test/kotlin/net/corda/node/services/messaging/ArtemisMessagingTests.kt +++ b/node/src/test/kotlin/net/corda/node/services/messaging/ArtemisMessagingTests.kt @@ -1,9 +1,10 @@ package net.corda.node.services.messaging +import net.corda.core.context.AuthServiceId import net.corda.core.crypto.generateKeyPair import net.corda.core.utilities.NetworkHostAndPort -import net.corda.node.services.RPCUserService -import net.corda.node.services.RPCUserServiceImpl +import net.corda.node.internal.security.RPCSecurityManager +import net.corda.node.internal.security.RPCSecurityManagerImpl import net.corda.node.services.config.NodeConfiguration import net.corda.node.services.config.configureWithDevSSLCertificate import net.corda.node.services.network.NetworkMapCacheImpl @@ -50,7 +51,7 @@ class ArtemisMessagingTests { private lateinit var config: NodeConfiguration private lateinit var database: CordaPersistence - private lateinit var userService: RPCUserService + private lateinit var securityManager: RPCSecurityManager private var messagingClient: P2PMessagingClient? = null private var messagingServer: ArtemisMessagingServer? = null @@ -58,7 +59,7 @@ class ArtemisMessagingTests { @Before fun setUp() { - userService = RPCUserServiceImpl(emptyList()) + securityManager = RPCSecurityManagerImpl.fromUserList(users = emptyList(), id = AuthServiceId("TEST")) config = testNodeConfiguration( baseDirectory = temporaryFolder.root.toPath(), myLegalName = ALICE.name) @@ -169,7 +170,7 @@ class ArtemisMessagingTests { } private fun createMessagingServer(local: Int = serverPort, rpc: Int = rpcPort): ArtemisMessagingServer { - return ArtemisMessagingServer(config, local, rpc, networkMapCache, userService).apply { + return ArtemisMessagingServer(config, local, rpc, networkMapCache, securityManager).apply { config.configureWithDevSSLCertificate() messagingServer = this } diff --git a/node/src/test/kotlin/net/corda/node/services/persistence/HibernateConfigurationTest.kt b/node/src/test/kotlin/net/corda/node/services/persistence/HibernateConfigurationTest.kt index 7c36962968..98c06829b8 100644 --- a/node/src/test/kotlin/net/corda/node/services/persistence/HibernateConfigurationTest.kt +++ b/node/src/test/kotlin/net/corda/node/services/persistence/HibernateConfigurationTest.kt @@ -868,7 +868,7 @@ class HibernateConfigurationTest { } /** - * Test invoking SQL query using JDBC connection (session) + * Test invoking SQL query using DB connection (session) */ @Test fun `test calling an arbitrary JDBC native query`() { diff --git a/node/src/test/kotlin/net/corda/node/services/vault/VaultQueryTests.kt b/node/src/test/kotlin/net/corda/node/services/vault/VaultQueryTests.kt index 7f1563880b..ed558efbe1 100644 --- a/node/src/test/kotlin/net/corda/node/services/vault/VaultQueryTests.kt +++ b/node/src/test/kotlin/net/corda/node/services/vault/VaultQueryTests.kt @@ -2037,7 +2037,7 @@ class VaultQueryTests { * USE CASE demonstrations (outside of mainline Corda) * * 1) Template / Tutorial CorDapp service using Vault API Custom Query to access attributes of IOU State - * 2) Template / Tutorial Flow using a JDBC session to execute a custom query + * 2) Template / Tutorial Flow using a DB session to execute a custom query * 3) Template / Tutorial CorDapp service query extension executing Named Queries via JPA * 4) Advanced pagination queries using Spring Data (and/or Hibernate/JPQL) */ diff --git a/testing/node-driver/src/main/kotlin/net/corda/testing/internal/RPCDriver.kt b/testing/node-driver/src/main/kotlin/net/corda/testing/internal/RPCDriver.kt index 086651a72e..62902a24a2 100644 --- a/testing/node-driver/src/main/kotlin/net/corda/testing/internal/RPCDriver.kt +++ b/testing/node-driver/src/main/kotlin/net/corda/testing/internal/RPCDriver.kt @@ -16,7 +16,7 @@ import net.corda.core.internal.div import net.corda.core.internal.uncheckedCast import net.corda.core.messaging.RPCOps import net.corda.core.utilities.NetworkHostAndPort -import net.corda.node.services.RPCUserService +import net.corda.node.internal.security.RPCSecurityManagerImpl import net.corda.node.services.messaging.ArtemisMessagingServer import net.corda.node.services.messaging.RPCServer import net.corda.node.services.messaging.RPCServerConfiguration @@ -428,17 +428,13 @@ data class RPCDriverDSL( minLargeMessageSize = ArtemisMessagingServer.MAX_FILE_SIZE isUseGlobalPools = false } - val userService = object : RPCUserService { - override fun getUser(username: String): User? = if (username == rpcUser.username) rpcUser else null - override val users: List get() = listOf(rpcUser) - override val id: AuthServiceId = AuthServiceId("RPC_DRIVER") - } + val rpcSecurityManager = RPCSecurityManagerImpl.fromUserList(users = listOf(rpcUser), id = AuthServiceId("TEST_SECURITY_MANAGER")) val rpcServer = RPCServer( ops, rpcUser.username, rpcUser.password, locator, - userService, + rpcSecurityManager, nodeLegalName, configuration ) diff --git a/testing/node-driver/src/test/java/net/corda/node/internal/security/PasswordTest.kt b/testing/node-driver/src/test/java/net/corda/node/internal/security/PasswordTest.kt new file mode 100644 index 0000000000..ccdf750343 --- /dev/null +++ b/testing/node-driver/src/test/java/net/corda/node/internal/security/PasswordTest.kt @@ -0,0 +1,96 @@ +package net.corda.node.internal.security + +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.IsEqual.equalTo +import org.hamcrest.core.IsNot.not +import org.junit.Test + +internal class PasswordTest { + + @Test + fun immutability() { + + val charArray = "dadada".toCharArray() + val password = Password(charArray) + assertThat(password.value, equalTo(charArray)) + + charArray[0] = 'm' + assertThat(password.value, not(equalTo(charArray))) + + val value = password.value + value[1] = 'e' + assertThat(password.value, not(equalTo(value))) + } + + @Test + fun constructor_and_getters() { + + val value = "dadada" + + assertThat(Password(value.toCharArray()).value, equalTo(value.toCharArray())) + assertThat(Password(value.toCharArray()).valueAsString, equalTo(value)) + + assertThat(Password(value).value, equalTo(value.toCharArray())) + assertThat(Password(value).valueAsString, equalTo(value)) + } + + @Test + fun equals() { + + val passwordValue1 = Password("value1") + val passwordValue2 = Password("value2") + val passwordValue12 = Password("value1") + + assertThat(passwordValue1, equalTo(passwordValue1)) + + assertThat(passwordValue1, not(equalTo(passwordValue2))) + assertThat(passwordValue2, not(equalTo(passwordValue1))) + + assertThat(passwordValue1, equalTo(passwordValue12)) + assertThat(passwordValue12, equalTo(passwordValue1)) + } + + @Test + fun hashcode() { + + val passwordValue1 = Password("value1") + val passwordValue2 = Password("value2") + val passwordValue12 = Password("value1") + + assertThat(passwordValue1.hashCode(), equalTo(passwordValue1.hashCode())) + + // not strictly required by hashCode() contract, but desirable + assertThat(passwordValue1.hashCode(), not(equalTo(passwordValue2.hashCode()))) + assertThat(passwordValue2.hashCode(), not(equalTo(passwordValue1.hashCode()))) + + assertThat(passwordValue1.hashCode(), equalTo(passwordValue12.hashCode())) + assertThat(passwordValue12.hashCode(), equalTo(passwordValue1.hashCode())) + } + + @Test + fun close() { + + val value = "ipjd1@pijmps112112" + val password = Password(value) + + password.use { + val readValue = it.valueAsString + assertThat(readValue, equalTo(value)) + } + + val readValue = password.valueAsString + assertThat(readValue, not(equalTo(value))) + } + + @Test + fun toString_is_masked() { + + val value = "ipjd1@pijmps112112" + val password = Password(value) + + val toString = password.toString() + + assertThat(toString, not(containsString(value))) + } +} \ No newline at end of file