diff --git a/.env.prod.example b/.env.prod.example index 0f88a23..e96e0fb 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -16,3 +16,7 @@ LETSENCRYPT_EMAIL=admin@our-place.ru # Резервное копирование (см. deploy/BACKUP.md). LMS_BACKUP_DIR=/opt/lms/backups LMS_BACKUP_KEEP_DAYS=14 + +# Внутренний токен между identity и progress (профиль студента). +# Обязателен в production, задайте длинную случайную строку. +LMS_INTERNAL_TOKEN=change-me-to-a-long-random-string diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index cd71d92..f548893 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,6 +18,9 @@ x-db-env: &db-env x-jwt-env: &jwt-env LMS_JWT_JWK_SET_URI: http://identity:8081/api/v1/auth/jwks.json +x-internal-env: &internal-env + LMS_INTERNAL_TOKEN: ${LMS_INTERNAL_TOKEN:?Задайте LMS_INTERNAL_TOKEN в .env.prod} + services: postgres: image: postgres:18 @@ -40,7 +43,7 @@ services: image: lms-identity:latest restart: unless-stopped environment: - <<: *db-env + <<: [*db-env, *internal-env] LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity depends_on: postgres: { condition: service_healthy } @@ -66,7 +69,7 @@ services: image: lms-progress:latest restart: unless-stopped environment: - <<: [*db-env, *jwt-env] + <<: [*db-env, *jwt-env, *internal-env] LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=progress LMS_SUBMISSIONS_DIR: /data/submissions LMS_IDENTITY_URI: http://identity:8081 diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalController.kt new file mode 100644 index 0000000..9e296fb --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalController.kt @@ -0,0 +1,65 @@ +package ru.ysu.lms.identity.internal + +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.common.ForbiddenException +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.UUID + +/** + * Внутренние эндпоинты identity. Gateway их не маршрутизирует, поэтому вместо + * пользовательского JWT используется общий сервисный токен. + */ +@RestController +@RequestMapping("/api/v1/internal") +class InternalController( + private val service: InternalService, + @Value("\${lms.internal.token}") private val internalToken: String, +) { + @GetMapping("/students/{studentId}/context") + fun studentContext( + @RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?, + @PathVariable studentId: UUID, + ): StudentContextDto { + ensureToken(token) + return service.studentContext(studentId) + } + + @PostMapping("/accounts/lookup") + fun lookupAccounts( + @RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?, + @RequestBody request: AccountLookupRequest, + ): List { + ensureToken(token) + return service.lookupAccounts(request.ids) + } + + @GetMapping("/groups/{groupId}") + fun group( + @RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?, + @PathVariable groupId: UUID, + ): InternalGroupDto { + ensureToken(token) + return service.group(groupId) + } + + private fun ensureToken(token: String?) { + if (token == null || + !MessageDigest.isEqual(token.toByteArray(StandardCharsets.UTF_8), internalToken.toByteArray(StandardCharsets.UTF_8)) + ) { + throw ForbiddenException(INVALID_TOKEN_MESSAGE) + } + } + + private companion object { + const val INTERNAL_TOKEN_HEADER = "X-Internal-Token" + const val INVALID_TOKEN_MESSAGE = "Неверный внутренний токен" + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt new file mode 100644 index 0000000..a88e898 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt @@ -0,0 +1,38 @@ +package ru.ysu.lms.identity.internal + +/** Контекст студента для внутренних агрегаторов (профиль преподавателя). */ +data class StudentContextDto( + val id: String, + val login: String, + val fullName: String, + val studentNo: String, + val status: String, + val enrollmentYear: Short? = null, + val groupId: String? = null, + val groupName: String? = null, +) + +/** Пакетный запрос логинов по идентификаторам аккаунтов. */ +data class AccountLookupRequest( + val ids: List = emptyList(), +) + +data class AccountInfoDto( + val id: String, + val login: String, + val role: String, +) + +/** Группа с текущим составом — без учётных данных. */ +data class InternalGroupDto( + val id: String, + val name: String, + val students: List, +) + +data class InternalGroupStudentDto( + val id: String, + val studentNo: String, + val fullName: String, + val status: String, +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt new file mode 100644 index 0000000..2d9dd37 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt @@ -0,0 +1,81 @@ +package ru.ysu.lms.identity.internal + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.ysu.lms.common.BadRequestException +import ru.ysu.lms.common.NotFoundException +import ru.ysu.lms.identity.repo.AccountRepository +import ru.ysu.lms.identity.repo.EnrollmentRepository +import ru.ysu.lms.identity.repo.GroupRepository +import ru.ysu.lms.identity.repo.StudentRepository +import java.util.UUID + +/** + * Read-model для внутренних вызовов: отдаёт данные без админского JWT. + * Доступ ограничен сервисным токеном на уровне [InternalController]. + */ +@Service +class InternalService( + private val accounts: AccountRepository, + private val students: StudentRepository, + private val enrollments: EnrollmentRepository, + private val groups: GroupRepository, +) { + @Transactional(readOnly = true) + fun studentContext(studentId: UUID): StudentContextDto { + val student = + students.findById(studentId).orElseThrow { + NotFoundException("Студент не найден") + } + val account = student.account ?: accounts.findById(studentId).orElseThrow { NotFoundException("Студент не найден") } + val enrollment = enrollments.findByStudentAccountId(studentId) + return StudentContextDto( + id = account.id.toString(), + login = account.login, + fullName = student.fullName, + studentNo = student.studentNo, + status = student.status.name, + enrollmentYear = student.enrollmentYear, + groupId = enrollment?.group?.id?.toString(), + groupName = enrollment?.group?.name, + ) + } + + /** Группа и её текущий состав. Пароли и временные пароли сюда не попадают. */ + @Transactional(readOnly = true) + fun group(groupId: UUID): InternalGroupDto { + val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } + val members = + enrollments + .findAllByGroupId(groupId) + .mapNotNull { enrollment -> + val student = enrollment.student ?: return@mapNotNull null + student.accountId?.let { accountId -> + InternalGroupStudentDto( + id = accountId.toString(), + studentNo = student.studentNo, + fullName = student.fullName, + status = student.status.name, + ) + } + }.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo })) + return InternalGroupDto(id = group.id.toString(), name = group.name, students = members) + } + + /** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */ + @Transactional(readOnly = true) + fun lookupAccounts(ids: List): List { + val requested = ids.map(::parseId).distinct() + if (requested.isEmpty()) return emptyList() + val found = accounts.findAllById(requested).associateBy { it.id } + return requested.mapNotNull { id -> + found[id]?.let { account -> + AccountInfoDto(id = account.id.toString(), login = account.login, role = account.role.name) + } + } + } + + private fun parseId(raw: String): UUID = + runCatching { UUID.fromString(raw.trim()) } + .getOrElse { throw BadRequestException("Некорректный идентификатор аккаунта") } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/security/SecurityConfig.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/security/SecurityConfig.kt index ff67f49..ddfb2fc 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/security/SecurityConfig.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/security/SecurityConfig.kt @@ -31,6 +31,8 @@ class SecurityConfig { "/api/v1/auth/setup", "/api/v1/auth/login", "/api/v1/auth/jwks.json", + // Внутренние эндпоинты защищены сервисным токеном в самом контроллере. + "/api/v1/internal/**", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", diff --git a/services/identity-service/src/main/resources/application.yml b/services/identity-service/src/main/resources/application.yml index b3b5243..1e313fc 100644 --- a/services/identity-service/src/main/resources/application.yml +++ b/services/identity-service/src/main/resources/application.yml @@ -37,6 +37,8 @@ springdoc: path: /v3/api-docs lms: + internal: + token: ${LMS_INTERNAL_TOKEN:dev-internal-token} jwt: # Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml). access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d} diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt new file mode 100644 index 0000000..3969042 --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt @@ -0,0 +1,331 @@ +package ru.ysu.lms.identity.internal + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import ru.ysu.lms.identity.domain.Account +import ru.ysu.lms.identity.domain.Enrollment +import ru.ysu.lms.identity.domain.GroupEntity +import ru.ysu.lms.identity.domain.Student +import ru.ysu.lms.identity.domain.StudentStatus +import ru.ysu.lms.identity.repo.AccountRepository +import ru.ysu.lms.identity.repo.EnrollmentRepository +import ru.ysu.lms.identity.repo.GroupRepository +import ru.ysu.lms.identity.repo.StudentRepository +import ru.ysu.lms.security.LmsRole +import java.util.UUID + +@SpringBootTest +@Testcontainers +@Transactional +class InternalControllerIntegrationTest { + companion object { + private const val TOKEN = "test-internal-token" + + @Container + val pg = + PostgreSQLContainer("postgres:18").apply { + withDatabaseName("lms") + withUsername("lms") + withPassword("lms") + } + + @JvmStatic + @DynamicPropertySource + fun properties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { pg.jdbcUrl } + registry.add("spring.datasource.username") { "lms" } + registry.add("spring.datasource.password") { "lms" } + registry.add("lms.internal.token") { TOKEN } + } + } + + @Autowired lateinit var accounts: AccountRepository + + @Autowired lateinit var students: StudentRepository + + @Autowired lateinit var enrollments: EnrollmentRepository + + @Autowired lateinit var groups: GroupRepository + + @Autowired lateinit var passwordEncoder: PasswordEncoder + + @Autowired lateinit var context: WebApplicationContext + + private val mockMvc: MockMvc by lazy { + MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + @Test + fun `valid token returns student context with current group`() { + val student = student(login = "ivanov", studentNo = "123456", groupName = "ПИ-21БО") + + mockMvc + .get("/api/v1/internal/students/${student.accountId}/context") { + header("X-Internal-Token", TOKEN) + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(student.accountId.toString()) } + jsonPath("$.login") { value("ivanov") } + jsonPath("$.fullName") { value("Иванов Иван Иванович") } + jsonPath("$.studentNo") { value("123456") } + jsonPath("$.status") { value("ACTIVE") } + jsonPath("$.enrollmentYear") { value(2025) } + jsonPath("$.groupId") { isNotEmpty() } + jsonPath("$.groupName") { value("ПИ-21БО") } + } + } + + @Test + fun `context without group returns nulls`() { + val student = student(login = "petrov", studentNo = "654321") + + mockMvc + .get("/api/v1/internal/students/${student.accountId}/context") { + header("X-Internal-Token", TOKEN) + }.andExpect { + status { isOk() } + jsonPath("$.groupId") { doesNotExist() } + jsonPath("$.groupName") { doesNotExist() } + } + } + + @Test + fun `valid token returns account logins for lookup`() { + val first = account("a.ivanov") + val second = account("p.petrov") + + mockMvc + .post("/api/v1/internal/accounts/lookup") { + header("X-Internal-Token", TOKEN) + contentType = MediaType.APPLICATION_JSON + content = """{"ids":["${first.id}","${second.id}"]}""" + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].id") { value(first.id.toString()) } + jsonPath("$[0].login") { value("a.ivanov") } + jsonPath("$[0].role") { value("STUDENT") } + jsonPath("$[1].login") { value("p.petrov") } + } + } + + @Test + fun `lookup ignores missing accounts and duplicates`() { + val existing = account("a.ivanov") + val missing = UUID.randomUUID() + + mockMvc + .post("/api/v1/internal/accounts/lookup") { + header("X-Internal-Token", TOKEN) + contentType = MediaType.APPLICATION_JSON + content = """{"ids":["${existing.id}","$missing","${existing.id}"]}""" + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].id") { value(existing.id.toString()) } + jsonPath("$[0].login") { value("a.ivanov") } + } + } + + @Test + fun `empty lookup returns empty list`() { + mockMvc + .post("/api/v1/internal/accounts/lookup") { + header("X-Internal-Token", TOKEN) + contentType = MediaType.APPLICATION_JSON + content = """{"ids":[]}""" + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(0) } + } + } + + @Test + fun `invalid lookup id is rejected`() { + mockMvc + .post("/api/v1/internal/accounts/lookup") { + header("X-Internal-Token", TOKEN) + contentType = MediaType.APPLICATION_JSON + content = """{"ids":["not-a-uuid"]}""" + }.andExpect { + status { isBadRequest() } + } + } + + @Test + fun `invalid token is forbidden`() { + val student = student(login = "sidorov", studentNo = "111111", groupName = "ПИ-21БО") + + mockMvc + .get("/api/v1/internal/students/${student.accountId}/context") { + header("X-Internal-Token", "wrong-token") + }.andExpect { + status { isForbidden() } + jsonPath("$.detail") { value("Неверный внутренний токен") } + } + + mockMvc + .post("/api/v1/internal/accounts/lookup") { + header("X-Internal-Token", "wrong-token") + contentType = MediaType.APPLICATION_JSON + content = """{"ids":[]}""" + }.andExpect { + status { isForbidden() } + } + } + + @Test + fun `missing token is forbidden`() { + val student = student(login = "sidorov", studentNo = "111111") + + mockMvc + .get("/api/v1/internal/students/${student.accountId}/context") { + }.andExpect { + status { isForbidden() } + } + + mockMvc + .post("/api/v1/internal/accounts/lookup") { + contentType = MediaType.APPLICATION_JSON + content = """{"ids":[]}""" + }.andExpect { + status { isForbidden() } + } + } + + @Test + fun `unknown student is not found`() { + mockMvc + .get("/api/v1/internal/students/${UUID.randomUUID()}/context") { + header("X-Internal-Token", TOKEN) + }.andExpect { + status { isNotFound() } + } + } + + @Test + fun `valid token returns group with students and no credentials`() { + val group = group("ПИ-21БО") + val petrov = enroll(group, login = "p.petrov", studentNo = "111111", lastName = "Петров") + val ivanov = enroll(group, login = "a.ivanov", studentNo = "222222", lastName = "Иванов") + + mockMvc + .get("/api/v1/internal/groups/${group.id}") { + header("X-Internal-Token", TOKEN) + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(group.id.toString()) } + jsonPath("$.name") { value("ПИ-21БО") } + jsonPath("$.students.length()") { value(2) } + jsonPath("$.students[0].id") { value(ivanov.accountId.toString()) } + jsonPath("$.students[0].studentNo") { value("222222") } + jsonPath("$.students[0].fullName") { value("Иванов Иван Иванович") } + jsonPath("$.students[0].status") { value("ACTIVE") } + jsonPath("$.students[1].id") { value(petrov.accountId.toString()) } + jsonPath("$.students[1].studentNo") { value("111111") } + jsonPath("$.students[0].tempPassword") { doesNotExist() } + jsonPath("$.students[0].login") { doesNotExist() } + jsonPath("$.students[0].passwordHash") { doesNotExist() } + } + } + + @Test + fun `group with invalid token is forbidden`() { + val group = group("ПИ-21БО") + + mockMvc + .get("/api/v1/internal/groups/${group.id}") { + header("X-Internal-Token", "wrong-token") + }.andExpect { + status { isForbidden() } + jsonPath("$.detail") { value("Неверный внутренний токен") } + } + } + + @Test + fun `unknown group is not found`() { + mockMvc + .get("/api/v1/internal/groups/${UUID.randomUUID()}") { + header("X-Internal-Token", TOKEN) + }.andExpect { + status { isNotFound() } + } + } + + private fun account(login: String): Account = + accounts.save( + Account( + login = login, + passwordHash = passwordEncoder.encode("secret123") ?: "", + role = LmsRole.STUDENT, + ), + ) + + private fun group(name: String): GroupEntity = groups.save(GroupEntity(name = name, enrollmentYear = 2025)) + + private fun enroll( + group: GroupEntity, + login: String, + studentNo: String, + lastName: String, + ): Student { + val student = createStudent(login = login, studentNo = studentNo, lastName = lastName) + enrollments.save(Enrollment(group = group, student = student)) + return student + } + + private fun student( + login: String, + studentNo: String, + groupName: String? = null, + ): Student { + val student = createStudent(login = login, studentNo = studentNo, lastName = "Иванов") + if (groupName != null) { + enrollments.save(Enrollment(group = group(groupName), student = student)) + } + return student + } + + private fun createStudent( + login: String, + studentNo: String, + lastName: String, + ): Student { + val account = account(login) + val student = + students.save( + Student( + studentNo = studentNo, + lastName = lastName, + firstName = "Иван", + middleName = "Иванович", + basis = "бюджет", + status = StudentStatus.ACTIVE, + enrollmentYear = 2025, + account = account, + ), + ) + assertEquals(account.id, student.accountId) + return student + } +}