Skip to content

Commit 22e3d5c

Browse files
authored
Merge pull request #5725 from espoon-voltti/sfi-rest-password-rotation
Salasanan rotatointi Suomi.fi viestit REST-toteutukseen + tiukempi integraatio SSM:n kanssa
2 parents a6cb5a6 + 4e9c9f3 commit 22e3d5c

16 files changed

+365
-19
lines changed

service/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ dependencies {
119119

120120
// AWS SDK
121121
implementation("software.amazon.awssdk:s3")
122+
implementation("software.amazon.awssdk:ssm")
122123
implementation("software.amazon.awssdk:sts")
123124
implementation("software.amazon.awssdk:ses")
124125

service/src/integrationTest/kotlin/fi/espoo/evaka/sficlient/SoapStackIntegrationTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ class SoapStackIntegrationTest {
134134
restEnabled = false,
135135
restAddress = null,
136136
restUsername = null,
137-
restPassword = null,
137+
restPasswordSsmName = null,
138138
)
139139

140140
@BeforeAll
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// SPDX-FileCopyrightText: 2017-2024 City of Espoo
2+
//
3+
// SPDX-License-Identifier: LGPL-2.1-or-later
4+
5+
package fi.espoo.evaka.sficlient.rest
6+
7+
import fi.espoo.evaka.Sensitive
8+
import java.util.concurrent.locks.ReentrantLock
9+
import kotlin.concurrent.withLock
10+
11+
class MockPasswordStore(initialPassword: String) : PasswordStore {
12+
private val lock = ReentrantLock()
13+
private val passwords: MutableList<String> = mutableListOf(initialPassword)
14+
private var indexByLabel: MutableMap<PasswordStore.Label, Int> =
15+
mutableMapOf(PasswordStore.Label.CURRENT to 0)
16+
17+
override fun getPassword(label: PasswordStore.Label): PasswordStore.VersionedPassword? =
18+
lock.withLock {
19+
val index = indexByLabel[label] ?: return null
20+
PasswordStore.VersionedPassword(
21+
Sensitive(passwords[index]),
22+
PasswordStore.Version(index.toLong()),
23+
)
24+
}
25+
26+
override fun putPassword(password: Sensitive<String>): PasswordStore.Version =
27+
lock.withLock {
28+
val index = passwords.size
29+
passwords += password.value
30+
PasswordStore.Version(index.toLong())
31+
}
32+
33+
override fun moveLabel(version: PasswordStore.Version, label: PasswordStore.Label) =
34+
lock.withLock { indexByLabel[label] = version.value.toInt() }
35+
}

service/src/integrationTest/kotlin/fi/espoo/evaka/sficlient/rest/MockSfiMessagesRestEndpoint.kt

+25-2
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,32 @@ class MockSfiMessagesRestEndpoint {
3737
)
3838
fun getAccessToken(@RequestBody body: AccessTokenRequestBody): ResponseEntity<Any> =
3939
lock.withLock {
40-
if (body.username == USERNAME && body.password == PASSWORD) {
40+
if (body.username == USERNAME && body.password == password) {
4141
val token = UUID.randomUUID().toString()
4242
tokens.add(token)
4343
ResponseEntity.ok(AccessTokenResponse(access_token = token, token_type = "bearer"))
4444
} else ResponseEntity.badRequest().body(ApiError("Invalid credentials"))
4545
}
4646

47+
@PostMapping("/v1/change-password")
48+
fun changePassword(
49+
@RequestHeader("Authorization") authorization: String?,
50+
@RequestBody body: ChangePasswordRequestBody,
51+
): ResponseEntity<Any> =
52+
lock.withLock {
53+
val accessToken = authorization?.removePrefix("Bearer ")
54+
if (!tokens.contains(accessToken)) {
55+
ResponseEntity.status(401).body(ApiError("Invalid token"))
56+
} else if (body.accessToken != accessToken) {
57+
ResponseEntity.status(400).body(ApiError("Invalid token in body"))
58+
} else if (body.currentPassword != password) {
59+
ResponseEntity.status(400).body(ApiError("Invalid password"))
60+
} else {
61+
password = body.newPassword
62+
ResponseEntity.ok(null)
63+
}
64+
}
65+
4766
@PostMapping(
4867
"/v1/files",
4968
consumes = [MediaType.MULTIPART_FORM_DATA_VALUE],
@@ -86,7 +105,7 @@ class MockSfiMessagesRestEndpoint {
86105

87106
companion object {
88107
const val USERNAME = "test-user"
89-
const val PASSWORD = "test-password"
108+
const val DEFAULT_PASSWORD = "test-password"
90109

91110
private val lock = ReentrantLock()
92111

@@ -95,17 +114,21 @@ class MockSfiMessagesRestEndpoint {
95114
private val messages =
96115
mutableMapOf<ExternalId, Pair<MessageId, NewMessageFromClientOrganisation>>()
97116
private var nextMessageId: MessageId = 1L
117+
private var password: String = DEFAULT_PASSWORD
98118

99119
fun reset() =
100120
lock.withLock {
101121
tokens.clear()
102122
files.clear()
103123
messages.clear()
104124
nextMessageId = 1L
125+
password = DEFAULT_PASSWORD
105126
}
106127

107128
fun clearTokens() = lock.withLock { tokens.clear() }
108129

130+
fun getCurrentPassword() = lock.withLock { password }
131+
109132
fun getCapturedFiles() = lock.withLock { files.toMap() }
110133

111134
fun getCapturedMessages() = lock.withLock { messages.toMap() }

service/src/integrationTest/kotlin/fi/espoo/evaka/sficlient/rest/SfiMessagesRestClientIntegrationTest.kt

+18-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import fi.espoo.evaka.sficlient.SfiMessage
1616
import java.net.URI
1717
import kotlin.test.assertContentEquals
1818
import kotlin.test.assertEquals
19+
import kotlin.test.assertNotEquals
1920
import org.junit.jupiter.api.BeforeEach
2021
import org.junit.jupiter.api.Test
2122

@@ -49,7 +50,7 @@ class SfiMessagesRestClientIntegrationTest : FullApplicationTest(resetDbBeforeEa
4950
restEnabled = true,
5051
restAddress = URI.create("http://localhost:$httpPort/public/mock-sfi-messages"),
5152
restUsername = MockSfiMessagesRestEndpoint.USERNAME,
52-
restPassword = Sensitive(MockSfiMessagesRestEndpoint.PASSWORD),
53+
restPasswordSsmName = null,
5354
// dummy fields only used by the SOAP implementation
5455
address = "",
5556
trustStore = KeystoreEnv(location = URI.create("")),
@@ -95,6 +96,10 @@ class SfiMessagesRestClientIntegrationTest : FullApplicationTest(resetDbBeforeEa
9596
assertEquals(message.documentKey, key)
9697
Document(key, fileContent, contentType = "content-type")
9798
},
99+
passwordStore =
100+
MockPasswordStore(
101+
initialPassword = MockSfiMessagesRestEndpoint.DEFAULT_PASSWORD
102+
),
98103
)
99104
}
100105

@@ -178,4 +183,16 @@ class SfiMessagesRestClientIntegrationTest : FullApplicationTest(resetDbBeforeEa
178183
client.send(message.copy(messageId = "message-id-2"))
179184
assertEquals(2, MockSfiMessagesRestEndpoint.getCapturedMessages().size)
180185
}
186+
187+
@Test
188+
fun `changing password works`() {
189+
val oldPassword = MockSfiMessagesRestEndpoint.getCurrentPassword()
190+
client.rotatePassword()
191+
val newPassword = MockSfiMessagesRestEndpoint.getCurrentPassword()
192+
assertNotEquals(oldPassword, newPassword)
193+
194+
// sending a message should still work after password change
195+
client.send(message)
196+
assertEquals(1, MockSfiMessagesRestEndpoint.getCapturedMessages().size)
197+
}
181198
}

service/src/integrationTest/resources/application-integration-test.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ logging:
116116
evaka.messaging: debug
117117
org:
118118
springframework:
119+
boot:
120+
autoconfigure: ERROR
119121
ws:
120122
client:
121123
MessageTracing:

service/src/main/kotlin/fi/espoo/evaka/EvakaEnv.kt

+13-4
Original file line numberDiff line numberDiff line change
@@ -441,8 +441,18 @@ data class SfiEnv(
441441
val restAddress: URI?,
442442
/** Username for the messages REST API, also known as "systemId" */
443443
val restUsername: String?,
444-
/** Password for the messages REST API */
445-
val restPassword: Sensitive<String>?,
444+
/**
445+
* SSM parameter name for the messages REST API password.
446+
*
447+
* The service instance must have the permission to perform the following operations on it:
448+
* - GetParameter
449+
* - PutParameter
450+
* - LabelParameterVersion
451+
*
452+
* Required first time manual setup: save the password received from Suomi.fi to SSM and add the
453+
* label CURRENT to it
454+
*/
455+
val restPasswordSsmName: String?,
446456
) {
447457
companion object {
448458
fun fromEnvironment(env: Environment) =
@@ -477,8 +487,7 @@ data class SfiEnv(
477487
restEnabled = env.lookup("evaka.integration.sfi.rest_enabled") ?: false,
478488
restAddress = env.lookup("evaka.integration.sfi.rest_address"),
479489
restUsername = env.lookup("evaka.integration.sfi.rest_username"),
480-
restPassword =
481-
env.lookup<String?>("evaka.integration.sfi.rest_password")?.let(::Sensitive),
490+
restPasswordSsmName = env.lookup("evaka.integration.sfi.rest_password_ssm_name"),
482491
)
483492
}
484493
}

service/src/main/kotlin/fi/espoo/evaka/sficlient/MockSfiMessagesClient.kt

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class MockSfiMessagesClient : SfiMessagesClient {
1919
lock.write { data[msg.messageId] = msg }
2020
}
2121

22+
override fun rotatePassword() {}
23+
2224
companion object {
2325
private val data = mutableMapOf<MessageId, SfiMessage>()
2426
private val lock = ReentrantReadWriteLock()

service/src/main/kotlin/fi/espoo/evaka/sficlient/SfiMessagesClient.kt

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
88

99
interface SfiMessagesClient {
1010
fun send(msg: SfiMessage)
11+
12+
fun rotatePassword()
1113
}
1214

1315
@JsonIgnoreProperties(value = ["language"]) // ignore legacy properties

service/src/main/kotlin/fi/espoo/evaka/sficlient/SfiMessagesSoapClient.kt

+4
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,10 @@ class SfiMessagesSoapClient(
267267
}
268268
}
269269

270+
override fun rotatePassword() {
271+
// not supported or needed with the SOAP client
272+
}
273+
270274
private inline fun <reified T> WebServiceTemplate.marshalSendAndReceiveAsType(request: Any): T =
271275
marshalSendAndReceive(request).let {
272276
it as? T
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// SPDX-FileCopyrightText: 2017-2024 City of Espoo
2+
//
3+
// SPDX-License-Identifier: LGPL-2.1-or-later
4+
5+
package fi.espoo.evaka.sficlient.rest
6+
7+
import fi.espoo.evaka.Sensitive
8+
import fi.espoo.evaka.SfiEnv
9+
import mu.KotlinLogging
10+
import software.amazon.awssdk.services.ssm.SsmClient
11+
import software.amazon.awssdk.services.ssm.model.GetParameterRequest
12+
import software.amazon.awssdk.services.ssm.model.LabelParameterVersionRequest
13+
import software.amazon.awssdk.services.ssm.model.ParameterNotFoundException
14+
import software.amazon.awssdk.services.ssm.model.ParameterType
15+
import software.amazon.awssdk.services.ssm.model.ParameterVersionNotFoundException
16+
import software.amazon.awssdk.services.ssm.model.PutParameterRequest
17+
import software.amazon.awssdk.services.ssm.model.SsmException
18+
19+
/** Backing store for a single password with support for multiple versions/labels */
20+
interface PasswordStore {
21+
/** Gets the password from the store which has the given label */
22+
fun getPassword(label: Label): VersionedPassword?
23+
24+
/**
25+
* Saves a new password to the store, returning its internal version that can be used for
26+
* applying labels
27+
*/
28+
fun putPassword(password: Sensitive<String>): Version
29+
30+
/** Moves the given label to a saved password that has the given version */
31+
fun moveLabel(version: Version, label: Label)
32+
33+
data class VersionedPassword(val password: Sensitive<String>, val version: Version)
34+
35+
/** An internal version number for a specific password value */
36+
@JvmInline value class Version(val value: Long)
37+
38+
/** Unique, transferable label for a password */
39+
enum class Label {
40+
PENDING,
41+
CURRENT,
42+
}
43+
}
44+
45+
class AwsSsmPasswordStore(private val client: SsmClient, env: SfiEnv) : PasswordStore {
46+
private val ssmName =
47+
env.restPasswordSsmName ?: error("SFI REST password SSM name is not configured")
48+
private val logger = KotlinLogging.logger {}
49+
50+
override fun getPassword(label: PasswordStore.Label): PasswordStore.VersionedPassword? {
51+
logger.info { "Fetching a password with label $label" }
52+
return try {
53+
client
54+
.getParameter(
55+
GetParameterRequest.builder()
56+
.name("$ssmName:$label")
57+
.withDecryption(true)
58+
.build()
59+
)
60+
.let { response ->
61+
PasswordStore.VersionedPassword(
62+
Sensitive(response.parameter().value()),
63+
PasswordStore.Version(response.parameter().version()),
64+
)
65+
}
66+
} catch (e: SsmException) {
67+
if (e is ParameterNotFoundException || e is ParameterVersionNotFoundException) null
68+
else throw e
69+
}
70+
}
71+
72+
override fun putPassword(password: Sensitive<String>): PasswordStore.Version =
73+
client
74+
.putParameter(
75+
PutParameterRequest.builder()
76+
.name(ssmName)
77+
.type(ParameterType.SECURE_STRING)
78+
.overwrite(true)
79+
.value(password.value)
80+
.build()
81+
)
82+
.let { response ->
83+
logger.info { "Saved a new password with version ${response.version()}" }
84+
PasswordStore.Version(response.version())
85+
}
86+
87+
override fun moveLabel(version: PasswordStore.Version, label: PasswordStore.Label) {
88+
logger.info { "Applying label $label to ${version.value}" }
89+
client.labelParameterVersion(
90+
LabelParameterVersionRequest.builder()
91+
.name(ssmName)
92+
.labels(label.toString())
93+
.parameterVersion(version.value)
94+
.build()
95+
)
96+
}
97+
}

service/src/main/kotlin/fi/espoo/evaka/sficlient/rest/RestSchema.kt

+14-1
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,17 @@ import okhttp3.MultipartBody
1414
import okhttp3.RequestBody.Companion.toRequestBody
1515

1616
// https://api.messages-qa.suomi.fi/api-docs
17-
data class ApiUrls(val token: HttpUrl, val files: HttpUrl, val messages: HttpUrl) {
17+
data class ApiUrls(
18+
val token: HttpUrl,
19+
val files: HttpUrl,
20+
val messages: HttpUrl,
21+
val changePassword: HttpUrl,
22+
) {
1823
constructor(
1924
base: HttpUrl
2025
) : this(
2126
token = base.newBuilder().addPathSegments("v1/token").build(),
27+
changePassword = base.newBuilder().addPathSegments("v1/change-password").build(),
2228
files = base.newBuilder().addPathSegments("v1/files").build(),
2329
messages = base.newBuilder().addPathSegments("v1/messages").build(),
2430
)
@@ -175,3 +181,10 @@ data class Address(
175181
require(countryCode.isNotBlank()) { "countryCode must not be blank" }
176182
}
177183
}
184+
185+
// https://api.messages-qa.suomi.fi/api-docs#model-ChangePasswordRequestBody
186+
data class ChangePasswordRequestBody(
187+
val accessToken: String,
188+
val currentPassword: String,
189+
val newPassword: String,
190+
)

0 commit comments

Comments
 (0)