mirror of
https://github.com/corda/corda.git
synced 2025-01-26 22:29:28 +00:00
Configurable authorization/authentication data sources [CORDA-827] (#2145)
* Add support for external data source of access control data (RPC/Shell users credential and permissions), with optional in-memory caching. * Support password encoded with Apache Shiro fully reversible Modular Crypt Format. * Introduce 'security' field in Node configuration and related docsite page.
This commit is contained in:
parent
991c59e753
commit
da38e6f673
@ -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:
|
||||
|
@ -63,8 +63,8 @@ class RPCStabilityTests {
|
||||
val executor = Executors.newScheduledThreadPool(1)
|
||||
fun startAndStop() {
|
||||
rpcDriver {
|
||||
val server = startRpcServer<RPCOps>(ops = DummyOps)
|
||||
startRpcClient<RPCOps>(server.get().broker.hostAndPort!!).get()
|
||||
val server = startRpcServer<RPCOps>(ops = DummyOps).get()
|
||||
startRpcClient<RPCOps>(server.broker.hostAndPort!!).get()
|
||||
}
|
||||
}
|
||||
repeat(5) {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ Corda nodes
|
||||
corda-configuration-file
|
||||
clientrpc
|
||||
shell
|
||||
node-auth-config
|
||||
node-database
|
||||
node-administration
|
||||
out-of-process-verification
|
136
docs/source/node-auth-config.rst
Normal file
136
docs/source/node-auth-config.rst
Normal file
@ -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 = "<jdbc connection string>"
|
||||
username = "<db username>"
|
||||
password = "<db user password>"
|
||||
driverClassName = "<JDBC driver>"
|
||||
}
|
||||
}
|
||||
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 <https://shiro.apache.org/command-line-hasher.html>`_
|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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}"
|
||||
|
@ -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 .*")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<Node>
|
||||
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<Unit>() {
|
||||
@Suspendable
|
||||
override fun call() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
class UserAuthServiceEmbedded : UserAuthServiceTest() {
|
||||
|
||||
private val rpcUser = User("user", "foo", permissions = setOf(
|
||||
Permissions.startFlow<DummyFlow>(),
|
||||
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<DummyFlow>(),
|
||||
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<String>)
|
||||
private data class RoleAndPermissions(val role: String, val permissions: List<String>)
|
||||
|
||||
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<UserAndRoles> = emptyList(),
|
||||
roleAndPermissions: List<RoleAndPermissions> = 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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<Unit>()
|
||||
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,
|
||||
|
@ -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://<host>:<port>/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
|
||||
|
@ -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 <RESULT> 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 <RESULT> guard(methodName: String, args: List<Any?>, action: () -> RESULT): RESULT {
|
||||
|
||||
context().requireEitherPermission(permissionsAllowing.invoke(methodName, args))
|
||||
return action()
|
||||
private inline fun <RESULT> guard(methodName: String, args: List<Class<*>>, 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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -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 = '*'
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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<RPCSecurityManagerImpl>()
|
||||
|
||||
/**
|
||||
* Instantiate RPCSecurityManager initialised with users data from a list of [User]
|
||||
*/
|
||||
fun fromUserList(id: AuthServiceId, users: List<User>) =
|
||||
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<String>, 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<User>,
|
||||
realmId: String,
|
||||
passwordEncryption: PasswordEncryption = PasswordEncryption.NONE) : AuthorizingRealm() {
|
||||
|
||||
private val authorizationInfoByUser: Map<String, AuthorizationInfo>
|
||||
private val authenticationInfoByUser: Map<String, AuthenticationInfo>
|
||||
|
||||
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<String>()
|
||||
stringPermissions = emptySet<String>()
|
||||
}
|
||||
}
|
||||
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<K, V> = org.apache.shiro.cache.Cache<K, V>
|
||||
|
||||
/**
|
||||
* Adapts a [com.google.common.cache.Cache] to a [org.apache.shiro.cache.Cache] implementation.
|
||||
*/
|
||||
private fun <K, V> Cache<K, V>.toShiroCache(name: String) = object : ShiroCache<K, V> {
|
||||
|
||||
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<String, ShiroCache<*, *>>()
|
||||
|
||||
override fun <K, V> getCache(name: String): ShiroCache<K, V> {
|
||||
val result = instances[name] ?: buildCache<K, V>(name)
|
||||
instances.putIfAbsent(name, result)
|
||||
return result as ShiroCache<K, V>
|
||||
}
|
||||
|
||||
private fun <K, V> buildCache(name: String) : ShiroCache<K, V> {
|
||||
logger.info("Constructing cache '$name' with maximumSize=$maxSize, TTL=${timeToLiveSeconds}s")
|
||||
return CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(timeToLiveSeconds, TimeUnit.SECONDS)
|
||||
.maximumSize(maxSize)
|
||||
.build<K, V>()
|
||||
.toShiroCache(name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val logger = loggerFor<GuavaCacheManager>()
|
||||
}
|
||||
}
|
@ -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<User>
|
||||
|
||||
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<User>) : 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 }
|
||||
}
|
@ -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<User>
|
||||
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<User>,
|
||||
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)
|
||||
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<User>? = 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<User>) = AuthService(
|
||||
dataSource = DataSource(
|
||||
type = AuthDataSourceType.INMEMORY,
|
||||
users = users,
|
||||
passwordEncryption = PasswordEncryption.NONE),
|
||||
id = AuthServiceId("NODE_CONFIG"))
|
||||
}
|
||||
}
|
||||
}
|
@ -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<Configuration, LoginListener> {
|
||||
private fun ConfigurationImpl.configureAddressSecurity(): Pair<Configuration, LoginListener> {
|
||||
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<AppConfigurationEntry> {
|
||||
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<String, *>, options: Map<String, *>) {
|
||||
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<String, CertificateChainCheckPolicy.Check> = 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
|
||||
|
@ -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) {
|
||||
|
@ -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<Actor, RpcPermissions> {
|
||||
private fun actorFrom(message: ClientMessage): Pair<Actor, AuthorizingSubject> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<String>): 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<String> = emptySet()) {
|
||||
|
||||
companion object {
|
||||
val NONE = RpcPermissions()
|
||||
val ALL = RpcPermissions(setOf("ALL"))
|
||||
}
|
||||
|
||||
fun coverAny(permissions: Set<String>) = !values.intersect(permissions + Permissions.all()).isEmpty()
|
||||
}
|
@ -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<String>>(), AuthenticationPlugin<String> {
|
||||
class CordaAuthenticationPlugin(private val rpcOps: CordaRPCOps, private val securityManager: RPCSecurityManager, private val nodeLegalName: CordaX500Name) : CRaSHPlugin<AuthenticationPlugin<String>>(), AuthenticationPlugin<String> {
|
||||
|
||||
override fun getImplementation(): AuthenticationPlugin<String> = 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> = String::class.java
|
||||
|
@ -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<Node>
|
||||
@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<Node>) {
|
||||
fun runLocalShell(node: StartedNode<Node>) {
|
||||
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 <T> runFlowFromString(invoke: (Class<out FlowLogic<T>>, Array<out Any?>) -> FlowProgressHandle<T>,
|
||||
inputData: String,
|
||||
clazz: Class<out FlowLogic<T>>,
|
||||
om: ObjectMapper = yamlInputMapper): FlowProgressHandle<T> {
|
||||
inputData: String,
|
||||
clazz: Class<out FlowLogic<T>>,
|
||||
om: ObjectMapper = yamlInputMapper): FlowProgressHandle<T> {
|
||||
// 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)
|
||||
|
@ -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<T>(val invocationContext:InvocationContext, val rpcPermissions: RpcPermissions, val block:() -> T) : Thread() {
|
||||
private class RPCContextRunner<T>(val invocationContext: InvocationContext, val authorizingSubject: AuthorizingSubject, val block:() -> T): Thread() {
|
||||
|
||||
private var result: CompletableFuture<T> = 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()
|
||||
|
@ -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<String>) =
|
||||
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)
|
||||
|
@ -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"))
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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`() {
|
||||
|
@ -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)
|
||||
*/
|
||||
|
@ -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<User> 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
|
||||
)
|
||||
|
@ -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)))
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user