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:
parent
5628aa5675
commit
8ced1f7925
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -35,4 +35,5 @@ dependencies {
|
|||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
testImplementation("com.h2database:h2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue