Implement invitation management for patients and add supporting APIs, services, and tests

oka: not tested yet

- Introduced `Invitation` entity, table schema, and repository.
- Added `InvitationController` with APIs for creating, resolving, and accepting invitations.
- Implemented `InvitationService` to handle invitation workflows, including token generation and validation.
- Created unit tests for controller, service, and repository layers.
- Added support for patient-subject relationships with `PatientSubject` entity and repository.
- Updated `PatientAccessChecker` to handle subject-based access control.
- Extended Keycloak provisioning service for invitation role management.
- Updated database migrations to include invitations and patient-subject tables.
- Enhanced security configuration to permit invitation resolution API.
- Updated build scripts and dependencies for testing support.
This commit is contained in:
oskar 2026-01-14 15:12:10 +01:00
parent 5628aa5675
commit 8ced1f7925
19 changed files with 738 additions and 2 deletions

View file

@ -53,6 +53,13 @@ Run all tests:
- `X-Local-Roles`: e.g., `ADMIN, DOCTOR, CAREGIVER`.
- `X-Tenant-Id`: Target tenant.
## 📨 Invitation Onboarding
- **Invite-only registration**: Admins create invites; users accept with a token.
- **Resolve endpoint**: `POST /api/v1/invites/resolve` returns only masked email + expiry.
- **Accept endpoint**: `POST /api/v1/invites/accept` links the authenticated user to a patient.
- **Token hashing**: Invitation tokens are stored as HMAC-SHA256 with a server-side pepper.
- **Config**: Set `app.invites.token-pepper` in `back001/app/src/main/resources/application.yml` for non-dev environments.
## 🔗 Key Services & Links
- **OpenAPI**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html)
- **Health**: [http://localhost:8080/health](http://localhost:8080/health)

View file

@ -35,4 +35,5 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("com.h2database:h2")
}

View file

@ -25,6 +25,7 @@ class SecurityConfig {
.authorizeHttpRequests {
it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
it.requestMatchers("/api/v1/demo").permitAll()
it.requestMatchers(HttpMethod.POST, "/api/v1/invites/resolve").permitAll()
it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN")
it.anyRequest().authenticated()
}

View file

@ -55,6 +55,8 @@ app:
notification-exchange: notification.events
medication-queue: medication.plan.created
notification-queue: notification.requested
invites:
token-pepper: change-me-in-prod
management:
endpoints:

View file

@ -0,0 +1,27 @@
create table invitations (
id varchar(64) primary key,
token_hash varchar(255) not null,
email varchar(255) not null,
role varchar(64) not null,
patient_id varchar(64) not null,
status varchar(32) not null,
expires_at timestamptz not null,
accepted_at timestamptz,
accepted_by_user_id varchar(64),
created_by_admin varchar(128),
tenant_id varchar(64) not null,
created_at timestamptz not null,
updated_at timestamptz not null,
created_by varchar(128),
updated_by varchar(128)
);
create unique index idx_invitations_token_hash on invitations(token_hash);
create index idx_invitations_status_expires_at on invitations(status, expires_at);
create index idx_invitations_tenant on invitations(tenant_id);
alter table invitations add constraint fk_invitations_patient
foreign key (patient_id) references patients(id) on delete cascade;
alter table invitations add constraint fk_invitations_accepted_by_user
foreign key (accepted_by_user_id) references users(id) on delete set null;

View file

@ -0,0 +1,19 @@
create table patient_subjects (
id varchar(64) primary key,
patient_id varchar(64) not null,
user_id varchar(64) not null,
tenant_id varchar(64) not null,
created_at timestamptz not null,
updated_at timestamptz not null,
created_by varchar(128),
updated_by varchar(128)
);
create index idx_patient_subjects_tenant on patient_subjects(tenant_id);
create unique index idx_patient_subjects_unique on patient_subjects(tenant_id, patient_id, user_id);
alter table patient_subjects add constraint fk_patient_subjects_patient
foreign key (patient_id) references patients(id) on delete cascade;
alter table patient_subjects add constraint fk_patient_subjects_user
foreign key (user_id) references users(id) on delete cascade;

View file

@ -0,0 +1,84 @@
package com.mosenioring.identity.api
import com.mosenioring.app.config.SecurityConfig
import com.mosenioring.common.web.ProblemDetailsAdvice
import com.mosenioring.identity.service.InvitationService
import com.mosenioring.identity.service.InviteResolveResult
import org.junit.jupiter.api.Test
import org.mockito.Mockito.`when`
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.ImportAutoConfiguration
import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.time.Instant
@WebMvcTest(controllers = [InvitationController::class])
@ContextConfiguration(classes = [InvitationController::class, SecurityConfig::class])
@ImportAutoConfiguration(
exclude = [
DataSourceAutoConfiguration::class,
DataSourceTransactionManagerAutoConfiguration::class,
HibernateJpaAutoConfiguration::class,
JpaRepositoriesAutoConfiguration::class
]
)
@Import(SecurityConfig::class, ProblemDetailsAdvice::class)
@AutoConfigureMockMvc
class InvitationControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@MockBean
private lateinit var invitationService: InvitationService
@MockBean
private lateinit var jwtDecoder: JwtDecoder
@Test
fun `resolves invite when valid`() {
val expiresAt = Instant.parse("2024-01-01T12:00:00Z")
`when`(invitationService.resolveInvite("token-123"))
.thenReturn(InviteResolveResult("PATIENT", "i***@example.com", expiresAt))
val body = """{ "token": "token-123" }"""
mockMvc.perform(
post("/api/v1/invites/resolve")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.role").value("PATIENT"))
.andExpect(jsonPath("$.emailMasked").value("i***@example.com"))
.andExpect(jsonPath("$.expiresAt").value(expiresAt.toString()))
}
@Test
fun `rejects expired invite`() {
`when`(invitationService.resolveInvite("expired-token"))
.thenThrow(IllegalArgumentException("Invalid invitation"))
val body = """{ "token": "expired-token" }"""
mockMvc.perform(
post("/api/v1/invites/resolve")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isBadRequest)
}
}

View file

@ -0,0 +1,69 @@
package com.mosenioring.identity.repo
import com.mosenioring.app.Application
import com.mosenioring.common.tenant.TenantContext
import com.mosenioring.identity.Invitation
import com.mosenioring.identity.Patient
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import java.time.Instant
import java.util.UUID
@DataJpaTest
@ContextConfiguration(classes = [Application::class])
@TestPropertySource(
properties = [
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.flyway.enabled=false"
]
)
class InvitationRepositoryTest {
@Autowired
private lateinit var invitationRepository: InvitationRepository
@Autowired
private lateinit var patientRepository: PatientRepository
@AfterEach
fun tearDown() {
TenantContext.clear()
}
@Test
fun `enforces unique token hash`() {
TenantContext.setTenantId("t1")
val patient = patientRepository.save(Patient(UUID.randomUUID().toString(), "invited@example.com"))
val invitation1 = Invitation(
id = UUID.randomUUID().toString(),
tokenHash = "hash-1",
email = "invited@example.com",
role = Invitation.ROLE_PATIENT,
patient = patient,
status = Invitation.STATUS_PENDING,
expiresAt = Instant.now().plusSeconds(3600)
)
val invitation2 = Invitation(
id = UUID.randomUUID().toString(),
tokenHash = "hash-1",
email = "invited@example.com",
role = Invitation.ROLE_PATIENT,
patient = patient,
status = Invitation.STATUS_PENDING,
expiresAt = Instant.now().plusSeconds(3600)
)
invitationRepository.save(invitation1)
invitationRepository.flush()
assertThrows(DataIntegrityViolationException::class.java) {
invitationRepository.saveAndFlush(invitation2)
}
}
}

View file

@ -7,6 +7,7 @@ import org.springframework.stereotype.Component
interface PatientRelationshipRepository {
fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean
fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean
fun isSubjectOf(tenantId: String, patientId: String, userId: String): Boolean
}
@Component
@ -20,7 +21,8 @@ class PatientAccessChecker(
return true
}
val userId = SecurityUtils.currentUserId() ?: return false
return relationships.isCaregiverOf(tenantId, patientId, userId) ||
return relationships.isSubjectOf(tenantId, patientId, userId) ||
relationships.isCaregiverOf(tenantId, patientId, userId) ||
relationships.isDoctorOf(tenantId, patientId, userId)
}
}

View file

@ -39,6 +39,7 @@ class PatientAccessCheckerTest {
SecurityContextHolder.getContext().authentication = auth
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user1")).thenReturn(false)
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user1")).thenReturn(false)
Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user1")).thenReturn(false)
assertFalse(checker.hasAccess("p1"))
}
@ -49,6 +50,18 @@ class PatientAccessCheckerTest {
SecurityContextHolder.getContext().authentication = auth
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user2")).thenReturn(true)
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user2")).thenReturn(false)
Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user2")).thenReturn(false)
assertTrue(checker.hasAccess("p1"))
}
@Test
fun `allows patient subject relationship`() {
val principal = LocalUserPrincipal("user3", "t1", emptySet())
val auth = UsernamePasswordAuthenticationToken(principal, "n/a", emptyList())
SecurityContextHolder.getContext().authentication = auth
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user3")).thenReturn(false)
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user3")).thenReturn(false)
Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user3")).thenReturn(true)
assertTrue(checker.hasAccess("p1"))
}
}

View file

@ -9,4 +9,7 @@ plugins {
dependencies {
api(project(":common"))
implementation(project(":modules:audit"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.mockito:mockito-core")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1")
}

View file

@ -0,0 +1,62 @@
package com.mosenioring.identity
import com.mosenioring.common.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.Index
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import java.time.Instant
@Entity
@Table(
name = "invitations",
uniqueConstraints = [UniqueConstraint(name = "uq_invitations_token_hash", columnNames = ["token_hash"])],
indexes = [Index(name = "idx_invitations_status_expires_at", columnList = "status,expires_at")]
)
class Invitation(
@Id
@Column(name = "id")
val id: String,
@Column(name = "token_hash", nullable = false, unique = true)
val tokenHash: String,
@Column(name = "email", nullable = false)
val email: String,
@Column(name = "role", nullable = false)
val role: String,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "patient_id", nullable = false)
val patient: Patient,
@Column(name = "status", nullable = false)
var status: String,
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant,
@Column(name = "accepted_at")
var acceptedAt: Instant? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "accepted_by_user_id")
var acceptedBy: User? = null,
@Column(name = "created_by_admin")
val createdByAdmin: String? = null
) : BaseEntity() {
companion object {
const val STATUS_PENDING = "PENDING"
const val STATUS_ACCEPTED = "ACCEPTED"
const val STATUS_EXPIRED = "EXPIRED"
const val ROLE_PATIENT = "PATIENT"
}
}

View file

@ -0,0 +1,21 @@
package com.mosenioring.identity
import com.mosenioring.common.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
@Entity
@Table(name = "patient_subjects")
class PatientSubject(
@Id
@Column(name = "id")
val id: String,
@Column(name = "patient_id", nullable = false)
val patientId: String,
@Column(name = "user_id", nullable = false)
val userId: String
) : BaseEntity()

View file

@ -0,0 +1,71 @@
package com.mosenioring.identity.api
import com.mosenioring.common.security.SecurityUtils
import com.mosenioring.identity.service.InvitationService
import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.Valid
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.servlet.support.ServletUriComponentsBuilder
@RestController
@RequestMapping("/api/v1")
class InvitationController(
private val invitationService: InvitationService
) {
@PostMapping("/admin/invites")
fun createInvite(
@Valid @RequestBody request: CreateInviteRequest,
servletRequest: HttpServletRequest
): ResponseEntity<CreateInviteResponse> {
val result = invitationService.createPatientInvite(request.email, SecurityUtils.currentUserId())
val inviteLink = buildInviteLink(servletRequest, result.token)
return ResponseEntity.ok(CreateInviteResponse(inviteLink, result.expiresAt))
}
@PostMapping("/invites/resolve")
fun resolveInvite(@Valid @RequestBody request: ResolveInviteRequest): ResponseEntity<ResolveInviteResponse> {
val result = invitationService.resolveInvite(request.token)
return ResponseEntity.ok(ResolveInviteResponse(result.role, result.emailMasked, result.expiresAt))
}
@PostMapping("/invites/accept")
fun acceptInvite(@Valid @RequestBody request: AcceptInviteRequest): ResponseEntity<AcceptInviteResponse> {
val userId = SecurityUtils.currentUserId() ?: throw IllegalArgumentException("Missing user")
val result = invitationService.acceptInvite(request.token, userId)
return ResponseEntity.ok(AcceptInviteResponse("accepted", result.patientId))
}
private fun buildInviteLink(request: HttpServletRequest, token: String): String {
val base = ServletUriComponentsBuilder.fromRequestUri(request)
.replacePath("/invite")
.replaceQuery(null)
.build()
.toUriString()
return "$base?token=$token"
}
}
data class CreateInviteRequest(
@field:Email val email: String
)
data class CreateInviteResponse(val inviteLink: String, val expiresAt: java.time.Instant)
data class ResolveInviteRequest(
@field:NotBlank val token: String
)
data class ResolveInviteResponse(val role: String, val emailMasked: String, val expiresAt: java.time.Instant)
data class AcceptInviteRequest(
@field:NotBlank val token: String
)
data class AcceptInviteResponse(val status: String, val patientId: String)

View file

@ -20,3 +20,11 @@ interface PatientCaregiverRepository : JpaRepository<PatientCaregiver, String> {
interface PatientDoctorRepository : JpaRepository<PatientDoctor, String> {
fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean
}
interface PatientSubjectRepository : JpaRepository<PatientSubject, String> {
fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean
}
interface InvitationRepository : JpaRepository<Invitation, String> {
fun findByTokenHash(tokenHash: String): Invitation?
}

View file

@ -6,11 +6,15 @@ import org.springframework.stereotype.Repository
@Repository
class PatientRelationshipRepositoryImpl(
private val caregiverRepository: PatientCaregiverRepository,
private val doctorRepository: PatientDoctorRepository
private val doctorRepository: PatientDoctorRepository,
private val subjectRepository: PatientSubjectRepository
) : PatientRelationshipRepository {
override fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean =
caregiverRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
override fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean =
doctorRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
override fun isSubjectOf(tenantId: String, patientId: String, userId: String): Boolean =
subjectRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
}

View file

@ -0,0 +1,191 @@
package com.mosenioring.identity.service
import com.mosenioring.common.security.LocalUserPrincipal
import com.mosenioring.common.tenant.TenantContext
import com.mosenioring.identity.Invitation
import com.mosenioring.identity.Patient
import com.mosenioring.identity.PatientSubject
import com.mosenioring.identity.User
import com.mosenioring.identity.repo.InvitationRepository
import com.mosenioring.identity.repo.PatientRepository
import com.mosenioring.identity.repo.PatientSubjectRepository
import com.mosenioring.identity.repo.UserRepository
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.beans.factory.annotation.Value
import java.security.SecureRandom
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
data class InviteCreationResult(val token: String, val expiresAt: Instant)
data class InviteResolveResult(val role: String, val emailMasked: String, val expiresAt: Instant)
data class InviteAcceptResult(val patientId: String, val role: String)
@Service
class InvitationService(
private val invitationRepository: InvitationRepository,
private val patientRepository: PatientRepository,
private val userRepository: UserRepository,
private val subjectRepository: PatientSubjectRepository,
private val keycloakProvisioningService: KeycloakProvisioningService,
@Value("\${app.invites.token-pepper:change-me}") private val tokenPepper: String
) {
@Transactional
@PreAuthorize("hasRole('ADMIN')")
fun createPatientInvite(email: String, createdByAdmin: String?): InviteCreationResult {
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" }
val patient = Patient(UUID.randomUUID().toString(), generatePatientPlaceholderName())
val savedPatient = patientRepository.save(patient)
keycloakProvisioningService.provisionUser(email, Invitation.ROLE_PATIENT)?.let { userId ->
val user = User(userId, email, Invitation.ROLE_PATIENT, "INVITED")
userRepository.save(user)
}
keycloakProvisioningService.sendSetPasswordEmail(email)
val token = generateToken()
val invitation = Invitation(
id = UUID.randomUUID().toString(),
tokenHash = hashToken(token),
email = email,
role = Invitation.ROLE_PATIENT,
patient = savedPatient,
status = Invitation.STATUS_PENDING,
expiresAt = Instant.now().plus(INVITE_TTL),
acceptedAt = null,
acceptedBy = null,
createdByAdmin = createdByAdmin
)
invitationRepository.save(invitation)
return InviteCreationResult(token, invitation.expiresAt)
}
@Transactional
fun resolveInvite(token: String): InviteResolveResult {
val invitation = invitationRepository.findByTokenHash(hashToken(token))
?: throw IllegalArgumentException("Invalid invitation")
if (invitation.status != Invitation.STATUS_PENDING || isExpired(invitation)) {
if (isExpired(invitation)) {
markExpired(invitation)
}
throw IllegalArgumentException("Invalid invitation")
}
return InviteResolveResult(invitation.role, maskEmail(invitation.email), invitation.expiresAt)
}
@Transactional
fun acceptInvite(token: String, authenticatedUserId: String): InviteAcceptResult {
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" }
val invitation = loadInvitation(token)
if (invitation.status != Invitation.STATUS_PENDING) {
throw IllegalArgumentException("Invitation not available")
}
if (isExpired(invitation)) {
markExpired(invitation)
throw IllegalArgumentException("Invitation expired")
}
val authenticatedEmail = resolveAuthenticatedEmail()
val user = userRepository.findById(authenticatedUserId).orElseGet {
if (authenticatedEmail.isNullOrBlank()) {
throw IllegalArgumentException("User not found")
}
if (!invitation.email.equals(authenticatedEmail, ignoreCase = true)) {
throw IllegalArgumentException("Invitation email mismatch")
}
val newUser = User(authenticatedUserId, authenticatedEmail, invitation.role, "ACTIVE")
userRepository.save(newUser)
}
if (!invitation.email.equals(user.email, ignoreCase = true)) {
throw IllegalArgumentException("Invitation email mismatch")
}
if (invitation.role == Invitation.ROLE_PATIENT) {
linkPatientToUser(invitation.patient.id, user.id)
}
invitation.status = Invitation.STATUS_ACCEPTED
invitation.acceptedAt = Instant.now()
invitation.acceptedBy = user
invitationRepository.save(invitation)
return InviteAcceptResult(invitation.patient.id, invitation.role)
}
private fun loadInvitation(token: String): Invitation =
invitationRepository.findByTokenHash(hashToken(token))
?: throw IllegalArgumentException("Invitation not found")
private fun isExpired(invitation: Invitation): Boolean =
Instant.now().isAfter(invitation.expiresAt)
private fun markExpired(invitation: Invitation) {
if (invitation.status == Invitation.STATUS_PENDING) {
invitation.status = Invitation.STATUS_EXPIRED
invitationRepository.save(invitation)
}
}
private fun linkPatientToUser(patientId: String, userId: String) {
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
if (!subjectRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)) {
val link = PatientSubject(UUID.randomUUID().toString(), patientId, userId)
subjectRepository.save(link)
}
}
private fun resolveAuthenticatedEmail(): String? {
val authentication = SecurityContextHolder.getContext().authentication ?: return null
return when (authentication) {
is JwtAuthenticationToken -> authentication.token.getClaimAsString("email")
?: authentication.token.getClaimAsString("preferred_username")
else -> (authentication.principal as? LocalUserPrincipal)?.userId
}
}
private fun generateToken(): String {
val bytes = ByteArray(TOKEN_BYTES)
secureRandom.nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun hashToken(token: String): String {
val mac = Mac.getInstance("HmacSHA256")
val key = SecretKeySpec(tokenPepper.toByteArray(Charsets.UTF_8), "HmacSHA256")
mac.init(key)
val hash = mac.doFinal(token.toByteArray(Charsets.UTF_8))
return hash.joinToString("") { "%02x".format(it) }
}
private fun maskEmail(email: String): String {
val atIndex = email.indexOf('@')
if (atIndex <= 0) {
return "***"
}
val name = email.substring(0, atIndex)
val domain = email.substring(atIndex + 1)
val masked = if (name.length == 1) "*" else "${name.first()}***"
return "$masked@$domain"
}
private fun generatePatientPlaceholderName(): String {
val suffix = UUID.randomUUID().toString().replace("-", "").take(6)
return "Patient-$suffix"
}
companion object {
private val INVITE_TTL = Duration.ofHours(24)
private const val TOKEN_BYTES = 32
private val secureRandom = SecureRandom()
}
}

View file

@ -0,0 +1,14 @@
package com.mosenioring.identity.service
import org.springframework.stereotype.Service
interface KeycloakProvisioningService {
fun provisionUser(email: String, role: String): String?
fun sendSetPasswordEmail(email: String)
}
@Service
class NoopKeycloakProvisioningService : KeycloakProvisioningService {
override fun provisionUser(email: String, role: String): String? = null
override fun sendSetPasswordEmail(email: String) = Unit
}

View file

@ -0,0 +1,137 @@
package com.mosenioring.identity.service
import com.mosenioring.common.tenant.TenantContext
import com.mosenioring.identity.Invitation
import com.mosenioring.identity.Patient
import com.mosenioring.identity.PatientSubject
import com.mosenioring.identity.User
import com.mosenioring.identity.repo.InvitationRepository
import com.mosenioring.identity.repo.PatientRepository
import com.mosenioring.identity.repo.PatientSubjectRepository
import com.mosenioring.identity.repo.UserRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.time.Instant
import java.util.Optional
import java.util.UUID
class InvitationServiceTest {
private val invitationRepository: InvitationRepository = mock()
private val patientRepository: PatientRepository = mock()
private val userRepository: UserRepository = mock()
private val subjectRepository: PatientSubjectRepository = mock()
private val keycloakProvisioningService: KeycloakProvisioningService = mock()
private val service = InvitationService(
invitationRepository,
patientRepository,
userRepository,
subjectRepository,
keycloakProvisioningService,
"test-pepper"
)
@BeforeEach
fun setup() {
TenantContext.setTenantId("t1")
}
@AfterEach
fun tearDown() {
TenantContext.clear()
}
@Test
fun `accepts valid invite`() {
val token = "token-123"
val patient = Patient("patient-1", "invite@example.com")
val invitation = Invitation(
id = UUID.randomUUID().toString(),
tokenHash = hashToken(token),
email = "invite@example.com",
role = Invitation.ROLE_PATIENT,
patient = patient,
status = Invitation.STATUS_PENDING,
expiresAt = Instant.now().plusSeconds(3600)
)
val user = User("user-1", "invite@example.com", Invitation.ROLE_PATIENT, "ACTIVE")
whenever(invitationRepository.findByTokenHash(invitation.tokenHash)).thenReturn(invitation)
whenever(userRepository.findById("user-1")).thenReturn(Optional.of(user))
whenever(subjectRepository.existsByTenantIdAndPatientIdAndUserId("t1", patient.id, user.id))
.thenReturn(false)
whenever(subjectRepository.save(any<PatientSubject>())).thenAnswer { it.arguments[0] as PatientSubject }
whenever(invitationRepository.save(any<Invitation>())).thenAnswer { it.arguments[0] as Invitation }
val result = service.acceptInvite(token, "user-1")
assertEquals(patient.id, result.patientId)
assertEquals(Invitation.ROLE_PATIENT, result.role)
assertEquals(Invitation.STATUS_ACCEPTED, invitation.status)
assertNotNull(invitation.acceptedAt)
assertEquals(user, invitation.acceptedBy)
}
@Test
fun `rejects expired invite`() {
val token = "expired-token"
val patient = Patient("patient-1", "invite@example.com")
val invitation = Invitation(
id = UUID.randomUUID().toString(),
tokenHash = hashToken(token),
email = "invite@example.com",
role = Invitation.ROLE_PATIENT,
patient = patient,
status = Invitation.STATUS_PENDING,
expiresAt = Instant.now().minusSeconds(60)
)
whenever(invitationRepository.findByTokenHash(invitation.tokenHash)).thenReturn(invitation)
whenever(invitationRepository.save(any<Invitation>())).thenAnswer { it.arguments[0] as Invitation }
assertThrows(IllegalArgumentException::class.java) {
service.acceptInvite(token, "user-1")
}
assertEquals(Invitation.STATUS_EXPIRED, invitation.status)
}
@Test
fun `rejects invite when email mismatches`() {
val token = "mismatch-token"
val patient = Patient("patient-1", "invite@example.com")
val invitation = Invitation(
id = UUID.randomUUID().toString(),
tokenHash = hashToken(token),
email = "invite@example.com",
role = Invitation.ROLE_PATIENT,
patient = patient,
status = Invitation.STATUS_PENDING,
expiresAt = Instant.now().plusSeconds(3600)
)
val user = User("user-1", "other@example.com", Invitation.ROLE_PATIENT, "ACTIVE")
whenever(invitationRepository.findByTokenHash(invitation.tokenHash)).thenReturn(invitation)
whenever(userRepository.findById("user-1")).thenReturn(Optional.of(user))
assertThrows(IllegalArgumentException::class.java) {
service.acceptInvite(token, "user-1")
}
}
private fun hashToken(token: String): String {
val mac = Mac.getInstance("HmacSHA256")
val key = SecretKeySpec("test-pepper".toByteArray(Charsets.UTF_8), "HmacSHA256")
mac.init(key)
val hash = mac.doFinal(token.toByteArray(Charsets.UTF_8))
return hash.joinToString("") { "%02x".format(it) }
}
}