major: Migration from better-auth to keycloak

This commit is contained in:
2025-10-28 10:40:38 +01:00
parent e5e063bbde
commit 36364a7977
77 changed files with 1444 additions and 2930 deletions

View File

@@ -339,34 +339,6 @@ paths:
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
####### Users #######
/users:
post:
summary: Create a new user
operationId: createUser
tags:
- user
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserDto"
responses:
"201":
description: User successfully created
content:
application/json:
schema:
$ref: "#/components/schemas/UserDto"
"400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
/users/{id}:
parameters:
- name: id
@@ -394,35 +366,6 @@ paths:
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
put:
summary: Update a user
operationId: updateUser
description: Updates a user. If no request body is provided, the user data will be synchronized from JWT token claims.
tags:
- user
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/UserDto"
responses:
"200":
description: User successfully updated
content:
application/json:
schema:
$ref: "#/components/schemas/UserDto"
"400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"404":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/NotFound"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
delete:
summary: Delete a user
operationId: deleteUser
@@ -1075,45 +1018,17 @@ components:
UserDto:
type: object
required:
- id
- keycloakId
- name
- status
- organizationRoles
- organizationId
properties:
id:
keycloakId:
type: string
name:
type: string
status:
$ref: "#/components/schemas/UserStatus"
organizationRoles:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/UserRole"
description: "Map of organization IDs to arrays of user roles in those organizations"
CreateUserDto:
type: object
required:
- id
- name
- status
properties:
id:
organizationId:
type: string
name:
type: string
status:
$ref: "#/components/schemas/UserStatus"
organizationRoles:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/UserRole"
description: "Map of organization IDs to arrays of user roles in those organizations"
nullable: true
UserStatus:
type: string

View File

@@ -49,13 +49,11 @@ dependencies {
implementation "com.openhtmltopdf:openhtmltopdf-slf4j:$openHtmlVersion"
implementation "com.openhtmltopdf:openhtmltopdf-svg-support:$openHtmlVersion"
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
implementation 'org.postgresql:postgresql'
implementation 'org.springframework.boot:spring-boot-testcontainers'
implementation 'org.testcontainers:postgresql'
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// implementation 'eu.europa.ec.joinup.sd-dss:dss-validation'
// implementation 'eu.europa.ec.joinup.sd-dss:dss-pades'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

View File

@@ -0,0 +1,45 @@
networks:
net:
driver: bridge
volumes:
postgres_data_testbed:
services:
db:
image: postgres:latest
container_name: postgres-testbed
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
env_file:
- .env
ports:
- 5532:5432
networks:
- net
volumes:
- postgres_data_testbed:/var/lib/postgresql/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.0
container_name: keycloak-testbed
command: start-dev
environment:
KC_DB: postgres
KC_DB_URL_HOST: db
KC_DB_USERNAME: ${POSTGRES_USER}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
KC_DB_DATABASE: ${POSTGRES_DB}
KC_DB_SCHEMA: public
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
env_file:
- .env
ports:
- 7080:8080
depends_on:
- db
networks:
- net

View File

@@ -5,7 +5,7 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedExc
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
// import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto
@@ -19,7 +19,7 @@ import java.util.UUID
class ApplicationFormService(
private val applicationFormRepository: ApplicationFormRepository,
private val applicationFormMapper: ApplicationFormMapper,
private val notificationService: NotificationService
// private val notificationService: NotificationService
) {
fun createApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm {
@@ -96,19 +96,19 @@ class ApplicationFormService(
val message = "Ein neuer Mitbestimmungsantrag '${applicationForm.name}' wurde von ${applicationForm.createdBy.name} eingereicht und wartet auf Ihre Bearbeitung."
val clickTarget = "/application-forms/${applicationForm.id}/0"
// Create separate notification for each role that should be notified
val rolesToNotify = listOf("admin", "works_council_member", "employer", "employee")
rolesToNotify.forEach { role ->
notificationService.createNotification(
title = title,
message = message,
clickTarget = clickTarget,
recipient = null,
role = role,
organizationId = applicationForm.organizationId,
type = NotificationType.INFO
)
}
// // Create separate notification for each role that should be notified
// val rolesToNotify = listOf("admin", "works_council_member", "employer", "employee")
//
// rolesToNotify.forEach { role ->
// notificationService.createNotification(
// title = title,
// message = message,
// clickTarget = clickTarget,
// recipient = null,
// role = role,
// organizationId = applicationForm.organizationId,
// type = NotificationType.INFO
// )
// }
}
}

View File

@@ -18,32 +18,16 @@ import org.springframework.http.HttpMethod
class SecurityConfig {
@Bean
@Order(1)
fun publicApiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher("/swagger-ui/**", "/v3/**", "/actuator/**")
csrf { disable() }
authorizeHttpRequests {
authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll)
authorize("/actuator/**", permitAll)
authorize(anyRequest, denyAll)
}
}
return http.build()
}
@Bean
@Order(2)
fun protectedApiSecurityFilterChain(
fun configure(
http: HttpSecurity,
customJwtAuthenticationConverter: CustomJwtAuthenticationConverter
): SecurityFilterChain {
http {
csrf { disable() }
authorizeHttpRequests {
// Allow user registration without authentication
authorize(HttpMethod.POST, "/users", permitAll)
authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll)
authorize("/actuator/**", permitAll)
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
@@ -52,10 +36,4 @@ class SecurityConfig {
}
return http.build()
}
@Bean
fun jwtDecoder(): JwtDecoder {
return NimbusJwtDecoder.withJwkSetUri("http://192.168.178.114:3001/api/auth/jwks")
.jwsAlgorithm(SignatureAlgorithm.ES512).build()
}
}

View File

@@ -1,83 +1,83 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedNotificationDto
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class NotificationController(
private val notificationService: NotificationService,
private val notificationMapper: NotificationMapper,
private val pagedNotificationMapper: PagedNotificationMapper
) : NotificationApi {
override fun getNotifications(page: Int, size: Int): ResponseEntity<PagedNotificationDto> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
val notifications = notificationService.getNotifications(
recipientId = recipientId,
organizationId = organizationId,
page = page,
size = size
)
return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications))
}
override fun getUnreadNotifications(): ResponseEntity<List<NotificationDto>> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
val notifications = notificationService.getUnreadNotifications(
recipientId = recipientId,
organizationId = organizationId
)
return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) })
}
override fun getUnreadNotificationCount(): ResponseEntity<kotlin.Long> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
val count = notificationService.getUnreadNotificationCount(
recipientId = recipientId,
organizationId = organizationId
)
return ResponseEntity.ok(count)
}
override fun markAllNotificationsAsRead(): ResponseEntity<Unit> {
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
notificationService.markAllAsRead(
recipientId = recipientId,
organizationId = organizationId
)
return ResponseEntity.noContent().build()
}
override fun markNotificationAsRead(id: UUID): ResponseEntity<NotificationDto> {
val notification = notificationService.markNotificationAsRead(id)
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
}
override fun createNotification(createNotificationDto: CreateNotificationDto): ResponseEntity<NotificationDto> {
val notification = notificationService.createNotification(createNotificationDto)
return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
}
}
// package com.betriebsratkanzlei.legalconsenthub.notification
//
// import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
// import com.betriebsratkanzlei.legalconsenthub_api.api.NotificationApi
// import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
// import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationDto
// import com.betriebsratkanzlei.legalconsenthub_api.model.PagedNotificationDto
// import org.springframework.http.ResponseEntity
// import org.springframework.security.core.context.SecurityContextHolder
// import org.springframework.web.bind.annotation.RestController
// import java.util.UUID
//
// @RestController
// class NotificationController(
// private val notificationService: NotificationService,
// private val notificationMapper: NotificationMapper,
// private val pagedNotificationMapper: PagedNotificationMapper
// ) : NotificationApi {
//
// override fun getNotifications(page: Int, size: Int): ResponseEntity<PagedNotificationDto> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// val notifications = notificationService.getNotifications(
// recipientId = recipientId,
// organizationId = organizationId,
// page = page,
// size = size
// )
//
// return ResponseEntity.ok(pagedNotificationMapper.toPagedNotificationDto(notifications))
// }
//
// override fun getUnreadNotifications(): ResponseEntity<List<NotificationDto>> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// val notifications = notificationService.getUnreadNotifications(
// recipientId = recipientId,
// organizationId = organizationId
// )
//
// return ResponseEntity.ok(notifications.map { notificationMapper.toNotificationDto(it) })
// }
//
// override fun getUnreadNotificationCount(): ResponseEntity<kotlin.Long> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// val count = notificationService.getUnreadNotificationCount(
// recipientId = recipientId,
// organizationId = organizationId
// )
//
// return ResponseEntity.ok(count)
// }
//
// override fun markAllNotificationsAsRead(): ResponseEntity<Unit> {
// val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
// val recipientId = principal.id ?: throw IllegalStateException("User ID not found")
// val organizationId = principal.organizationId ?: throw IllegalStateException("Organization ID not found")
//
// notificationService.markAllAsRead(
// recipientId = recipientId,
// organizationId = organizationId
// )
//
// return ResponseEntity.noContent().build()
// }
//
// override fun markNotificationAsRead(id: UUID): ResponseEntity<NotificationDto> {
// val notification = notificationService.markNotificationAsRead(id)
// return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
// }
//
// override fun createNotification(createNotificationDto: CreateNotificationDto): ResponseEntity<NotificationDto> {
// val notification = notificationService.createNotification(createNotificationDto)
// return ResponseEntity.ok(notificationMapper.toNotificationDto(notification))
// }
// }

View File

@@ -1,76 +1,84 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
interface NotificationRepository : JpaRepository<Notification, UUID> {
fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page<Notification>
fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List<Notification>
fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long
@Query("""
SELECT n FROM Notification n WHERE
(n.recipient.id = :recipientId) OR
(n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
(n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
ORDER BY n.createdAt DESC
""")
fun findUserNotificationsByOrgRole(
@Param("recipientId") recipientId: String,
@Param("organizationIds") organizationIds: List<String>,
@Param("orgRolePairs") orgRolePairs: List<String>,
pageable: Pageable
): Page<Notification>
@Query("""
SELECT n FROM Notification n WHERE
((n.recipient.id = :recipientId) OR
(n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
(n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
AND n.isRead = false
ORDER BY n.createdAt DESC
""")
fun findUnreadUserNotificationsByOrgRole(
@Param("recipientId") recipientId: String,
@Param("organizationIds") organizationIds: List<String>,
@Param("orgRolePairs") orgRolePairs: List<String>
): List<Notification>
@Query("""
SELECT COUNT(n) FROM Notification n WHERE
((n.recipient.id = :recipientId) OR
(n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
(n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
AND n.isRead = false
""")
fun countUnreadUserNotifications(
@Param("recipientId") recipientId: String,
@Param("organizationIds") organizationIds: List<String>,
@Param("orgRolePairs") orgRolePairs: List<String>
): Long
@Modifying
@Query("""
UPDATE Notification n SET n.isRead = true WHERE
(n.recipient.id = :recipientId) OR
(n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
(n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
""")
fun markAllUserNotificationsAsRead(
@Param("recipientId") recipientId: String,
@Param("organizationIds") organizationIds: List<String>,
@Param("orgRolePairs") orgRolePairs: List<String>
)
@Modifying
@Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :recipientId")
fun markAllAsReadByRecipientId(@Param("recipientId") recipientId: String)
}
// package com.betriebsratkanzlei.legalconsenthub.notification
//
// import org.springframework.data.domain.Page
// import org.springframework.data.domain.Pageable
// import org.springframework.data.jpa.repository.JpaRepository
// import org.springframework.data.jpa.repository.Modifying
// import org.springframework.data.jpa.repository.Query
// import org.springframework.data.repository.query.Param
// import org.springframework.stereotype.Repository
// import java.util.UUID
//
// @Repository
// interface NotificationRepository : JpaRepository<Notification, UUID> {
//
// fun findByRecipientIdOrderByCreatedAtDesc(recipientId: String?, pageable: Pageable): Page<Notification>
// fun findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId: String?): List<Notification>
// fun countByRecipientIdAndIsReadFalse(recipientId: String?): Long
//
// @Query(
// """
// SELECT n FROM Notification n WHERE
// (n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
// ORDER BY n.createdAt DESC
// """
// )
// fun findUserNotificationsByOrgRole(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>,
// pageable: Pageable
// ): Page<Notification>
//
// @Query(
// """
// SELECT n FROM Notification n WHERE
// ((n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
// AND n.isRead = false
// ORDER BY n.createdAt DESC
// """
// )
// fun findUnreadUserNotificationsByOrgRole(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>
// ): List<Notification>
//
// @Query(
// """
// SELECT COUNT(n) FROM Notification n WHERE
// ((n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = '')))
// AND n.isRead = false
// """
// )
// fun countUnreadUserNotifications(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>
// ): Long
//
// @Modifying
// @Query(
// """
// UPDATE Notification n SET n.isRead = true WHERE
// (n.recipient.keycloakId = :recipientId) OR
// (n.recipient IS NULL AND CONCAT(n.organizationId, ':', n.role) IN :orgRolePairs) OR
// (n.recipient IS NULL AND n.organizationId IN :organizationIds AND (n.role IS NULL OR n.role = ''))
// """
// )
// fun markAllUserNotificationsAsRead(
// @Param("recipientId") recipientId: String,
// @Param("organizationIds") organizationIds: List<String>,
// @Param("orgRolePairs") orgRolePairs: List<String>
// )
//
// @Modifying
// @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.keycloakId = :recipientId")
// fun markAllAsReadByRecipientId(@Param("recipientId") recipientId: String)
// }

View File

@@ -1,127 +1,127 @@
package com.betriebsratkanzlei.legalconsenthub.notification
import com.betriebsratkanzlei.legalconsenthub.user.User
import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
import com.betriebsratkanzlei.legalconsenthub.user.UserRoleConverter
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service
class NotificationService(
private val notificationRepository: NotificationRepository,
private val notificationMapper: NotificationMapper,
private val userRepository: UserRepository,
private val userRoleConverter: UserRoleConverter
) {
fun createNotification(createNotificationDto: CreateNotificationDto): Notification {
val notification = notificationMapper.toNotification(createNotificationDto)
return notificationRepository.save(notification)
}
fun createNotification(
title: String,
message: String,
clickTarget: String,
recipient: User?,
role: String,
organizationId: String,
type: NotificationType = NotificationType.INFO
): Notification {
val notification = Notification(
title = title,
message = message,
clickTarget = clickTarget,
recipient = recipient,
role = role,
type = type,
organizationId = organizationId
)
return notificationRepository.save(notification)
}
fun getNotifications(
recipientId: String,
organizationId: String,
page: Int = 0,
size: Int = 20
): Page<Notification> {
val user = userRepository.findById(recipientId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
val pageable = PageRequest.of(page, size)
return if (userRoles.isNotEmpty()) {
notificationRepository.findUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs, pageable)
} else {
notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable)
}
}
fun getUnreadNotifications(
recipientId: String,
organizationId: String
): List<Notification> {
val user = userRepository.findById(recipientId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
return if (userRoles.isNotEmpty()) {
notificationRepository.findUnreadUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs)
} else {
notificationRepository.findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId)
}
}
fun getUnreadNotificationCount(
recipientId: String,
organizationId: String
): Long {
val user = userRepository.findById(recipientId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
return if (userRoles.isNotEmpty()) {
notificationRepository.countUnreadUserNotifications(recipientId, listOf(organizationId), orgRolePairs)
} else {
notificationRepository.countByRecipientIdAndIsReadFalse(recipientId)
}
}
@Transactional
fun markAllAsRead(
recipientId: String,
organizationId: String
) {
val user = userRepository.findById(recipientId)
.orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
if (userRoles.isNotEmpty()) {
notificationRepository.markAllUserNotificationsAsRead(recipientId, listOf(organizationId), orgRolePairs)
} else {
notificationRepository.markAllAsReadByRecipientId(recipientId)
}
}
@Transactional
fun markNotificationAsRead(notificationId: UUID): Notification {
val notification = notificationRepository.findById(notificationId)
.orElseThrow { IllegalArgumentException("Notification not found with id: $notificationId") }
notification.isRead = true
return notificationRepository.save(notification)
}
}
// package com.betriebsratkanzlei.legalconsenthub.notification
//
// import com.betriebsratkanzlei.legalconsenthub.user.User
// import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
// import com.betriebsratkanzlei.legalconsenthub.user.UserRoleConverter
// import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
// import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
// import org.springframework.data.domain.Page
// import org.springframework.data.domain.PageRequest
// import org.springframework.stereotype.Service
// import org.springframework.transaction.annotation.Transactional
// import java.util.UUID
//
// @Service
// class NotificationService(
// private val notificationRepository: NotificationRepository,
// private val notificationMapper: NotificationMapper,
// private val userRepository: UserRepository,
// private val userRoleConverter: UserRoleConverter
// ) {
//
// fun createNotification(createNotificationDto: CreateNotificationDto): Notification {
// val notification = notificationMapper.toNotification(createNotificationDto)
// return notificationRepository.save(notification)
// }
//
// fun createNotification(
// title: String,
// message: String,
// clickTarget: String,
// recipient: User?,
// role: String,
// organizationId: String,
// type: NotificationType = NotificationType.INFO
// ): Notification {
// val notification = Notification(
// title = title,
// message = message,
// clickTarget = clickTarget,
// recipient = recipient,
// role = role,
// type = type,
// organizationId = organizationId
// )
// return notificationRepository.save(notification)
// }
//
// fun getNotifications(
// recipientId: String,
// organizationId: String,
// page: Int = 0,
// size: Int = 20
// ): Page<Notification> {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// val pageable = PageRequest.of(page, size)
// return if (userRoles.isNotEmpty()) {
// notificationRepository.findUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs, pageable)
// } else {
// notificationRepository.findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable)
// }
// }
//
// fun getUnreadNotifications(
// recipientId: String,
// organizationId: String
// ): List<Notification> {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// return if (userRoles.isNotEmpty()) {
// notificationRepository.findUnreadUserNotificationsByOrgRole(recipientId, listOf(organizationId), orgRolePairs)
// } else {
// notificationRepository.findByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(recipientId)
// }
// }
//
// fun getUnreadNotificationCount(
// recipientId: String,
// organizationId: String
// ): Long {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// return if (userRoles.isNotEmpty()) {
// notificationRepository.countUnreadUserNotifications(recipientId, listOf(organizationId), orgRolePairs)
// } else {
// notificationRepository.countByRecipientIdAndIsReadFalse(recipientId)
// }
// }
//
// @Transactional
// fun markAllAsRead(
// recipientId: String,
// organizationId: String
// ) {
// val user = userRepository.findById(recipientId)
// .orElseThrow { IllegalArgumentException("User not found with id: $recipientId") }
//
// val userRoles = userRoleConverter.getRolesForOrganization(user.organizationRoles, organizationId)
// val orgRolePairs = userRoles.map { role -> "$organizationId:${role.value}" }
//
// if (userRoles.isNotEmpty()) {
// notificationRepository.markAllUserNotificationsAsRead(recipientId, listOf(organizationId), orgRolePairs)
// } else {
// notificationRepository.markAllAsReadByRecipientId(recipientId)
// }
// }
//
// @Transactional
// fun markNotificationAsRead(notificationId: UUID): Notification {
// val notification = notificationRepository.findById(notificationId)
// .orElseThrow { IllegalArgumentException("Notification not found with id: $notificationId") }
// notification.isRead = true
// return notificationRepository.save(notification)
// }
// }

View File

@@ -11,12 +11,12 @@ class CustomJwtAuthenticationConverter : Converter<Jwt, AbstractAuthenticationTo
override fun convert(jwt: Jwt): AbstractAuthenticationToken {
val authorities: Collection<GrantedAuthority> = emptyList()
val userId = jwt.getClaimAsString("id")
val userId = jwt.subject
val username = jwt.getClaimAsString("name")
val organizationId = jwt.getClaimAsString("organizationId")
val roles = jwt.getClaimAsStringList("roles") ?: emptyList()
val realmAccess = jwt.getClaimAsMap("realm_access")
val roles = (realmAccess?.get("roles") as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
val principal = CustomJwtTokenPrincipal(userId, username, organizationId, roles)
val principal = CustomJwtTokenPrincipal(userId, username, roles)
return CustomJwtAuthentication(jwt, principal, authorities)
}

View File

@@ -3,6 +3,6 @@ package com.betriebsratkanzlei.legalconsenthub.security
data class CustomJwtTokenPrincipal(
val id: String? = null,
val name: String? = null,
val roles: List<String> = emptyList(),
val organizationId: String? = null,
val roles: List<String> = emptyList()
)

View File

@@ -0,0 +1,55 @@
package com.betriebsratkanzlei.legalconsenthub.security
import com.betriebsratkanzlei.legalconsenthub.user.UserService
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
@Component
class JwtUserSyncFilter(val userService: UserService) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val logger = LoggerFactory.getLogger(JwtUserSyncFilter::class.java)
val auth: Authentication? = SecurityContextHolder.getContext().authentication
println("JwtUserSyncFilter invoked for request: ${request.requestURI}")
try {
if (auth is JwtAuthenticationToken && auth.isAuthenticated) {
val jwt: Jwt = auth.token
val keycloakId = jwt.subject
val name = jwt.getClaimAsString("name")
// Extract organization information from JWT
val organizationClaim = jwt.getClaimAsMap("organization")
val organizationId = organizationClaim?.values?.firstOrNull()?.let { orgData ->
if (orgData is Map<*, *>) {
orgData["id"] as? String
} else {
null
}
}
val user = UserDto(keycloakId, name, organizationId)
if (keycloakId != null) {
userService.createUpdateUserFromJwt(user)
}
}
} catch (ex: Exception) {
logger.warn("Failed to sync user from JWT: {}", ex.message, ex)
}
filterChain.doFilter(request, response)
}
}

View File

@@ -13,18 +13,13 @@ import java.time.LocalDateTime
class User(
@Id
@Column(nullable = false)
var id: String,
var keycloakId: String,
@Column(nullable = false)
var name: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var status: UserStatus = UserStatus.ACTIVE,
@ElementCollection
@CollectionTable(name = "user_organization_roles", joinColumns = [JoinColumn(name = "user_id")])
var organizationRoles: MutableSet<UserOrganizationRole> = mutableSetOf(),
@Column(nullable = true)
var organizationId: String? = null,
@CreatedDate
@Column(nullable = false)

View File

@@ -1,11 +1,8 @@
package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.api.UserApi
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.RestController
@RestController
@@ -13,33 +10,11 @@ class UserController(
private val userService: UserService,
private val userMapper: UserMapper
) : UserApi {
override fun createUser(createUserDto: CreateUserDto): ResponseEntity<UserDto> {
val user = userService.createUser(createUserDto)
return ResponseEntity.status(201).body(userMapper.toUserDto(user))
}
override fun getUserById(id: String): ResponseEntity<UserDto> {
val user = userService.getUserById(id)
return ResponseEntity.ok(userMapper.toUserDto(user))
}
override fun updateUser(id: String, userDto: UserDto?): ResponseEntity<UserDto> {
val user = if (userDto != null) {
// Update with provided data
userService.updateUser(id, userDto)
} else {
// Update from JWT data
val principal = SecurityContextHolder.getContext().authentication.principal as CustomJwtTokenPrincipal
val userId = principal.id ?: throw IllegalArgumentException("User ID missing from JWT")
val organizationId = principal.organizationId
val roles = principal.roles
userService.updateUserFromJwt(userId, organizationId, roles)
}
return ResponseEntity.ok(userMapper.toUserDto(user))
}
override fun deleteUser(id: String): ResponseEntity<Unit> {
userService.deleteUser(id)
return ResponseEntity.noContent().build()

View File

@@ -4,31 +4,22 @@ import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import org.springframework.stereotype.Component
@Component
class UserMapper(
private val roleConverter: UserRoleConverter
) {
class UserMapper {
fun toUserDto(user: User): UserDto {
val organizationRolesDto = roleConverter.convertToMap(user.organizationRoles)
return UserDto(
id = user.id,
keycloakId = user.keycloakId,
name = user.name,
status = user.status,
organizationRoles = organizationRolesDto
organizationId = user.organizationId
)
}
fun toUser(userDto: UserDto): User {
val user = User(
id = userDto.id,
keycloakId = userDto.keycloakId,
name = userDto.name,
status = userDto.status
organizationId = userDto.organizationId
)
userDto.organizationRoles.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
}
return user
}
}

View File

@@ -1,13 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.user
import jakarta.persistence.Column
import jakarta.persistence.Embeddable
@Embeddable
data class UserOrganizationRole(
@Column(name = "organization_id", nullable = false)
val organizationId: String,
@Column(name = "role", nullable = false)
val role: String
)

View File

@@ -1,42 +0,0 @@
package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub_api.model.UserRole
import org.springframework.stereotype.Component
@Component
object UserRoleConverter {
fun getRolesForOrganization(organizationRoles: Set<UserOrganizationRole>, organizationId: String): List<UserRole> {
return organizationRoles
.filter { it.organizationId == organizationId }
.mapNotNull { orgRole ->
try {
UserRole.valueOf(orgRole.role)
} catch (e: IllegalArgumentException) {
null
}
}
}
fun setRolesForOrganization(organizationRoles: MutableSet<UserOrganizationRole>, organizationId: String, roles: List<UserRole>) {
organizationRoles.removeIf { it.organizationId == organizationId }
roles.forEach { role ->
organizationRoles.add(UserOrganizationRole(organizationId, role.name))
}
}
fun convertToMap(organizationRoles: Set<UserOrganizationRole>): Map<String, List<UserRole>> {
return organizationRoles
.groupBy { it.organizationId }
.mapValues { (_, roles) ->
roles.mapNotNull { orgRole ->
try {
UserRole.valueOf(orgRole.role)
} catch (e: IllegalArgumentException) {
null
}
}
}
.filterValues { it.isNotEmpty() }
}
}

View File

@@ -3,9 +3,7 @@ package com.betriebsratkanzlei.legalconsenthub.user
import com.betriebsratkanzlei.legalconsenthub.error.UserAlreadyExistsException
import com.betriebsratkanzlei.legalconsenthub.error.UserNotFoundException
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateUserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserDto
import com.betriebsratkanzlei.legalconsenthub_api.model.UserStatus
import jakarta.transaction.Transactional
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
@@ -13,7 +11,7 @@ import org.springframework.stereotype.Service
@Service
class UserService(
private val userRepository: UserRepository,
private val roleConverter: UserRoleConverter
private val userMapper: UserMapper
) {
fun getCurrentUser(): User {
@@ -24,21 +22,32 @@ class UserService(
.orElseThrow { UserNotFoundException(userId) }
}
fun createUser(createUserDto: CreateUserDto): User {
if (userRepository.existsById(createUserDto.id)) {
throw UserAlreadyExistsException(createUserDto.id)
@Transactional
fun createUpdateUserFromJwt(userDto: UserDto): User {
val existingUser = userRepository.findById(userDto.keycloakId)
if (existingUser.isEmpty) {
return createUser(userDto)
} else {
val user = existingUser.get()
if (user.organizationId == null && userDto.organizationId != null) {
user.organizationId = userDto.organizationId
}
return updateUser(userMapper.toUserDto(user))
}
}
fun createUser(userDto: UserDto): User {
if (userRepository.existsById(userDto.keycloakId)) {
throw UserAlreadyExistsException(userDto.keycloakId)
}
val user = User(
id = createUserDto.id,
name = createUserDto.name,
status = createUserDto.status
keycloakId = userDto.keycloakId,
name = userDto.name,
organizationId = userDto.organizationId
)
createUserDto.organizationRoles?.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
}
return userRepository.save(user)
}
@@ -48,43 +57,19 @@ class UserService(
}
@Transactional
fun updateUser(userId: String, userDto: UserDto): User {
val user = userRepository.findById(userId)
.orElseThrow { UserNotFoundException(userId) }
fun updateUser(userDto: UserDto): User {
val user = userRepository.findById(userDto.keycloakId)
.orElseThrow { UserNotFoundException(userDto.keycloakId) }
user.name = userDto.name
user.status = userDto.status
user.organizationRoles.clear()
userDto.organizationRoles.forEach { (orgId, roles) ->
roleConverter.setRolesForOrganization(user.organizationRoles, orgId, roles)
// Only update organization if it's not already set
if (user.organizationId == null && userDto.organizationId != null) {
user.organizationId = userDto.organizationId
}
return userRepository.save(user)
}
@Transactional
fun updateUserFromJwt(userId: String, jwtOrganizationId: String?, jwtRoles: List<String>?): User {
val existingUser = userRepository.findById(userId)
.orElseThrow { UserNotFoundException(userId) }
if (jwtOrganizationId != null && !jwtRoles.isNullOrEmpty()) {
existingUser.organizationRoles.removeIf { it.organizationId == jwtOrganizationId }
jwtRoles.forEach { role ->
val normalizedRole = role.lowercase().replace("_", "_")
existingUser.organizationRoles.add(
UserOrganizationRole(
organizationId = jwtOrganizationId,
role = normalizedRole
)
)
}
}
return userRepository.save(existingUser)
}
fun deleteUser(userId: String) {
userRepository.deleteById(userId)
}

View File

@@ -32,6 +32,13 @@ spring:
init:
platform: h2
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:7080/realms/legalconsenthub
jwk-set-uri: http://localhost:7080/realms/legalconsenthub/protocol/openid-connect/certs
logging:
level:
org: