Initial commit: LMS microservices scaffold

- Gradle multi-module: libs/{common-http,security-jwt,markdown,test-support},
  services/{identity,academic,progress,gateway}, web (Thymeleaf+htmx)
- identity-service: JWT auth (RSA), accounts, students, groups, enrollment,
  import from XLSX/CSV, credentials export, password reset
- academic-service: lab import from JSON/zip (markdown + YAML + variants),
  levels, API
- progress-service: schema for student levels and variant assignments
- gateway: Spring Cloud Gateway, JWT validation, route propagation
- web: Server-side UI with flex/footer layout, light/dark theme toggle,
  admin dashboard, groups CRUD, student portal placeholder
- docker-compose: PostgreSQL 18
- Tests: identity smoke test with Testcontainers
This commit is contained in:
2026-09-10 19:46:13 +03:00
commit 2cee104633
125 changed files with 4752 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(libs.springBootStarterWeb)
implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator)
implementation(libs.springBootStarterDataJpa)
implementation(libs.springBootStarterSecurity)
implementation(libs.springBootStarterOauth2ResourceServer)
implementation(libs.springBootStarterFlyway)
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml)
implementation(libs.jacksonModuleKotlin)
implementation(libs.springdocStarterWebmvcUi)
implementation(project(":libs:common-http"))
implementation(project(":libs:markdown"))
implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support"))
}
springBoot {
mainClass.set("ru.ysu.lms.academic.AcademicServiceApplicationKt")
}

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.academic
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class AcademicServiceApplication
fun main(args: Array<String>) {
runApplication<AcademicServiceApplication>(*args)
}

View File

@@ -0,0 +1,33 @@
package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.lab.*
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
@RestController
@RequestMapping("/api/v1/labs")
class LabController(private val labService: LabService) {
@GetMapping
fun list(): List<LabSummary> = labService.list()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id)
@PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun importLab(@RequestParam("file") file: MultipartFile): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл")
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun delete(@PathVariable id: UUID) {
labService.delete(id)
}
}

View File

@@ -0,0 +1,40 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OrderBy
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "labs")
class Lab(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false)
var number: Int = 0,
@Column(nullable = false, length = 200)
var title: String = "",
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "semester_id", nullable = false)
var semester: Semester? = null,
@Column(name = "description_md", nullable = false)
var descriptionMd: String = "",
@Column(name = "deadline_at")
var deadlineAt: Instant? = null,
@Column(name = "defense_at")
var defenseAt: Instant? = null,
@Column(name = "points_max", nullable = false, columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal = java.math.BigDecimal.valueOf(5.0),
@Column(nullable = false, length = 16)
var status: String = "draft",
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,31 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "lab_levels")
class LabLevel(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_id", nullable = false)
var lab: Lab? = null,
@Column(nullable = false, length = 32)
var code: String = "",
@Column(nullable = false, length = 100)
var title: String = "",
@Column(nullable = false)
var description: String = "",
@Column(nullable = false)
var position: Int = 0,
@Column(name = "points_max", columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal? = null,
)

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "semesters")
class Semester(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, length = 100)
var title: String = "",
@Column(nullable = false)
var year: Int = 0,
@Column(nullable = false)
var active: Boolean = false,
)

View File

@@ -0,0 +1,32 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "variants")
class Variant(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_id", nullable = false)
var lab: Lab? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_level_id", nullable = false)
var labLevel: LabLevel? = null,
@Column(nullable = false, length = 32)
var number: String = "",
@Column(nullable = false, length = 200)
var title: String = "",
@Column(name = "statement_md", nullable = false)
var statementMd: String = "",
@Column(nullable = false)
var position: Int = 0,
)

View File

@@ -0,0 +1,80 @@
package ru.ysu.lms.academic.lab
import java.math.BigDecimal
data class LabMeta(
val number: Int,
val title: String,
val pointsMax: BigDecimal = BigDecimal.valueOf(5.0),
val deadlineAt: String? = null,
val defenseAt: String? = null,
val status: String = "draft",
val levels: List<LabLevelMeta> = emptyList(),
)
data class LabLevelMeta(
val code: String,
val title: String,
val description: String = "",
val position: Int = 0,
val pointsMax: BigDecimal? = null,
)
data class VariantMeta(
val level: String,
val number: String,
val title: String = "",
val statement: String = "",
)
data class LabImportBundle(
val meta: LabMeta,
val descriptionMd: String,
val variants: List<VariantMeta>,
val assets: Map<String, ByteArray> = emptyMap(),
)
data class LabImportResult(
val labId: String,
val number: Int,
val title: String,
val levelCount: Int,
val variantCount: Int,
)
data class LabSummary(
val id: String,
val number: Int,
val title: String,
val status: String,
val semesterTitle: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: BigDecimal,
val variantCount: Int,
)
data class LabDetail(
val id: String,
val number: Int,
val title: String,
val status: String,
val descriptionMd: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: BigDecimal,
val levels: List<LabLevelDetail>,
)
data class LabLevelDetail(
val code: String,
val title: String,
val variants: List<VariantDetail>,
)
data class VariantDetail(
val id: String,
val number: String,
val title: String,
val statementMd: String,
)

View File

@@ -0,0 +1,165 @@
package ru.ysu.lms.academic.lab
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.math.BigDecimal
import java.util.zip.ZipInputStream
@Component
class LabImportParser {
private val yaml = ObjectMapper(YAMLFactory())
.registerModule(KotlinModule.Builder().build())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private val json = ObjectMapper()
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
val lower = filename.lowercase()
if (!(lower.endsWith(".zip") || lower.endsWith(".json"))) {
throw BadRequestException("Формат должен быть .zip (рекомендуется) или .json")
}
return if (lower.endsWith(".json")) {
parseJson(bytes)
} else {
parseZip(bytes)
}
}
private fun parseJson(bytes: ByteArray): LabImportBundle {
val tree = json.readTree(bytes)
val meta = LabMeta(
number = tree.path("number").asInt(),
title = tree.path("title").asText(),
pointsMax = BigDecimal.valueOf(tree.path("points_max").asDouble(5.0)),
deadlineAt = tree.path("deadline").asText("").ifBlank { null },
defenseAt = tree.path("defense_at").asText("").ifBlank { null },
status = tree.path("status").asText("published"),
levels = parseLevels(tree),
)
val descriptionMd = tree.path("common_requirements").asText("")
val variants = parseVariantsFromTree(tree, meta.levels)
return LabImportBundle(meta, descriptionMd, variants)
}
private fun parseZip(bytes: ByteArray): LabImportBundle {
val entries = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
entries[entry.name] = zis.readAllBytes()
}
entry = zis.nextEntry
}
}
val metaEntry = entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java)
val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
val readmeMd = readmeEntry?.let { String(it.value, Charsets.UTF_8) } ?: ""
val variants = mutableListOf<VariantMeta>()
val assets = mutableMapOf<String, ByteArray>()
for ((path, data) in entries) {
val p = path.replace('\\', '/')
when {
p.contains("/variants/") || p.startsWith("variants/") -> {
val content = String(data, Charsets.UTF_8)
val variant = parseVariantFile(p, content, meta.levels)
if (variant != null) variants.add(variant)
}
p.contains("/assets/") || p.startsWith("assets/") -> {
assets[p] = data
}
}
}
if (variants.isEmpty()) {
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
}
return LabImportBundle(meta, readmeMd, variants, assets)
}
private fun parseLevels(tree: JsonNode): List<LabLevelMeta> {
val levels = tree.path("levels")
if (levels.isArray && levels.size() > 0) {
return levels.map { n ->
LabLevelMeta(
code = n.path("code").asText(),
title = n.path("title").asText(""),
description = n.path("description").asText(""),
position = n.path("position").asInt(0),
)
}
}
return listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
private fun parseVariantsFromTree(tree: JsonNode, levels: List<LabLevelMeta>): List<VariantMeta> {
val variants = tree.path("variants")
if (!variants.isArray) return emptyList()
val levelMap = levels.associateBy { it.code }
return variants.map { v ->
val levelCode = normalizeLevel(v.path("level").asText("basic"))
if (!levelMap.containsKey(levelCode)) {
throw BadRequestException("Неизвестный уровень: ${v.path("level").asText()}")
}
VariantMeta(
level = levelCode,
number = v.path("number").asText(),
title = v.path("title").asText(""),
statement = v.path("statement").asText(""),
)
}
}
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
val levelMap = levels.associateBy { it.code }
val lines = content.lines()
val firstSep = lines.indexOfFirst { it.trim() == "---" }
if (firstSep != 0) {
return VariantMeta(
level = "basic",
number = path.substringAfterLast("/").removeSuffix(".md"),
title = "",
statement = content.trim(),
)
}
val secondSep = lines.withIndex().firstOrNull { it.index != firstSep && it.value.trim() == "---" }?.index ?: -1
if (secondSep < 0) return null
val fmLines = lines.subList(1, secondSep)
val fm = mutableMapOf<String, String>()
for (line in fmLines) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) fm[parts[0].trim()] = parts[1].trim()
}
val levelCode = normalizeLevel(fm["level"] ?: "basic")
if (levelMap[levelCode] == null) return null
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
return VariantMeta(
level = levelCode,
number = fm["number"] ?: return null,
title = fm["title"] ?: "",
statement = body.ifBlank { "—" },
)
}
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
}
}

View File

@@ -0,0 +1,137 @@
package ru.ysu.lms.academic.lab
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.SemesterRepository
import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.BadRequestException
import java.math.BigDecimal
import java.util.UUID
@Service
class LabService(
private val labs: LabRepository,
private val labLevels: LabLevelRepository,
private val variantsRepo: VariantRepository,
private val semesters: SemesterRepository,
private val parser: LabImportParser,
) {
@Transactional
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult {
val bundle = parser.parse(filename, bytes)
val semester = semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
val lab = labs.save(Lab(
number = bundle.meta.number,
title = bundle.meta.title,
semester = semester,
descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status,
))
val levelMetas = bundle.meta.levels.ifEmpty {
listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
val levelEntities = levelMetas.map { meta ->
labLevels.save(LabLevel(
lab = lab,
code = meta.code,
title = meta.title,
description = meta.description,
position = meta.position,
pointsMax = meta.pointsMax,
))
}
val levelByCode = levelEntities.associateBy { it.code }
bundle.variants.forEach { vmeta ->
val level = levelByCode[vmeta.level]
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
variantsRepo.save(Variant(
lab = lab,
labLevel = level,
number = vmeta.number,
title = vmeta.title,
statementMd = vmeta.statement,
))
}
return LabImportResult(
labId = lab.id.toString(),
number = lab.number,
title = lab.title,
levelCount = levelEntities.size,
variantCount = bundle.variants.size,
)
}
@Transactional(readOnly = true)
fun list(): List<LabSummary> =
labs.findAllByOrderByNumberAsc().map { lab ->
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
LabSummary(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
semesterTitle = lab.semester?.let { "${it.title} ${it.year}" } ?: "—",
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
variantCount = variants.size,
)
}
@Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
val levels = labLevels.findByLabIdOrderByPosition(lab.id)
return LabDetail(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
descriptionMd = lab.descriptionMd,
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
levels = levels.map { level ->
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
LabLevelDetail(
code = level.code,
title = level.title,
variants = vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
)
}
@Transactional
fun delete(labId: UUID) {
if (!labs.existsById(labId)) {
throw NotFoundException("Лабораторная не найдена")
}
labs.deleteById(labId)
}
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Lab
import java.util.UUID
interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab>
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Semester
import java.util.UUID
interface SemesterRepository : JpaRepository<Semester, UUID> {
fun findFirstByActiveTrue(): Semester?
}

View File

@@ -0,0 +1,14 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
import java.util.UUID
interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel>
}
interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
}

View File

@@ -0,0 +1,60 @@
package ru.ysu.lms.academic.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder =
NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@Bean
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt ->
val role = jwt.getClaimAsString("role")
if (role.isNullOrBlank()) emptyList()
else listOf(SimpleGrantedAuthority("ROLE_$role"))
}
return converter
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth ->
oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
}
return http.build()
}
companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
}
}

View File

@@ -0,0 +1,37 @@
server:
port: ${SERVER_PORT:8082}
spring:
application:
name: academic-service
datasource:
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=academic}
username: ${LMS_DB_USER:lms}
password: ${LMS_DB_PASSWORD:lms}
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: academic
flyway:
enabled: true
locations: classpath:db/migration
schemas: academic
default-schema: academic
create-schemas: true
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs:
path: /v3/api-docs

View File

@@ -0,0 +1,2 @@
-- Baseline for the academic schema (courses, articles, labs, levels, variants).
CREATE SCHEMA IF NOT EXISTS academic;

View File

@@ -0,0 +1,88 @@
-- Courses, articles, semesters, labs, levels, variants for the academic service.
CREATE TABLE courses (
id uuid PRIMARY KEY DEFAULT uuidv7(),
title varchar(200) NOT NULL,
summary text NOT NULL DEFAULT '',
position integer NOT NULL DEFAULT 0,
published boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE articles (
id uuid PRIMARY KEY DEFAULT uuidv7(),
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title varchar(200) NOT NULL,
slug varchar(200) NOT NULL,
summary text NOT NULL DEFAULT '',
body_md text NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'draft',
position integer NOT NULL DEFAULT 0,
published_at timestamptz,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT articles_course_slug_unique UNIQUE (course_id, slug)
);
CREATE TABLE article_assets (
id uuid PRIMARY KEY DEFAULT uuidv7(),
article_id uuid REFERENCES articles(id) ON DELETE CASCADE,
filename varchar(255) NOT NULL,
stored_path varchar(512) NOT NULL,
mime varchar(128) NOT NULL DEFAULT '',
size bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE semesters (
id uuid PRIMARY KEY DEFAULT uuidv7(),
title varchar(100) NOT NULL,
year integer NOT NULL,
active boolean NOT NULL DEFAULT false
);
CREATE TABLE labs (
id uuid PRIMARY KEY DEFAULT uuidv7(),
number integer NOT NULL,
title varchar(200) NOT NULL,
semester_id uuid NOT NULL REFERENCES semesters(id) ON DELETE RESTRICT,
description_md text NOT NULL DEFAULT '',
deadline_at timestamptz,
defense_at timestamptz,
points_max numeric(4,1) NOT NULL DEFAULT 5,
status varchar(16) NOT NULL DEFAULT 'draft',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT labs_number_semester_unique UNIQUE (number, semester_id)
);
CREATE TABLE lab_levels (
id uuid PRIMARY KEY DEFAULT uuidv7(),
lab_id uuid NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
code varchar(32) NOT NULL,
title varchar(100) NOT NULL,
description text NOT NULL DEFAULT '',
position integer NOT NULL DEFAULT 0,
points_max numeric(4,1),
CONSTRAINT lab_levels_lab_code_unique UNIQUE (lab_id, code)
);
CREATE TABLE variants (
id uuid PRIMARY KEY DEFAULT uuidv7(),
lab_id uuid NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
lab_level_id uuid NOT NULL REFERENCES lab_levels(id) ON DELETE CASCADE,
number varchar(32) NOT NULL,
title varchar(200) NOT NULL DEFAULT '',
statement_md text NOT NULL DEFAULT '',
position integer NOT NULL DEFAULT 0,
CONSTRAINT variants_lab_level_number_unique UNIQUE (lab_id, lab_level_id, number)
);
CREATE TABLE variant_examples (
id uuid PRIMARY KEY DEFAULT uuidv7(),
variant_id uuid NOT NULL REFERENCES variants(id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
input text NOT NULL DEFAULT '',
output text NOT NULL DEFAULT '',
explanation text NOT NULL DEFAULT ''
);

View File

@@ -0,0 +1,4 @@
-- Seed data: default semester for development.
INSERT INTO semesters (id, title, year, active)
VALUES ('00000000-0000-7000-8000-000000000001', 'осень', 2026, true)
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,19 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(platform(libs.springCloudBom))
implementation(libs.springCloudStarterGatewayWebflux)
implementation(libs.springBootStarterActuator)
implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support"))
}
springBoot {
mainClass.set("ru.ysu.lms.gateway.GatewayApplicationKt")
}

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.gateway
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class GatewayApplication
fun main(args: Array<String>) {
runApplication<GatewayApplication>(*args)
}

View File

@@ -0,0 +1,66 @@
package ru.ysu.lms.gateway.security
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.cloud.gateway.filter.GlobalFilter
import org.springframework.core.Ordered
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.JwtException
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
/**
* Validates the bearer token on every routed request and forwards the resolved
* identity to the downstream services as `X-Account-*` headers.
*/
@Component
class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter, Ordered {
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val path = exchange.request.path.value()
if (isPublic(path)) {
return chain.filter(exchange)
}
val token = bearerToken(exchange)
?: return unauthorized(exchange, "Требуется вход")
return try {
val jwt = jwtDecoder.decode(token)
val request = exchange.request.mutate()
.header("X-Account-Id", jwt.subject.orEmpty())
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
.build()
chain.filter(exchange.mutate().request(request).build())
} catch (_: JwtException) {
unauthorized(exchange, "Недействительный или истёкший токен")
}
}
override fun getOrder(): Int = -100
private fun isPublic(path: String): Boolean =
path.startsWith("/actuator") ||
path == "/api/v1/auth/login" ||
path == "/api/v1/auth/setup" ||
path == "/api/v1/auth/health" ||
path == "/api/v1/auth/jwks.json"
private fun bearerToken(exchange: ServerWebExchange): String? {
val header = exchange.request.headers.getFirst("Authorization") ?: return null
if (!header.startsWith("Bearer ")) {
return null
}
return header.removePrefix("Bearer ").trim().ifBlank { null }
}
private fun unauthorized(exchange: ServerWebExchange, message: String): Mono<Void> {
val response = exchange.response
response.statusCode = HttpStatus.UNAUTHORIZED
response.headers.contentType = MediaType.APPLICATION_JSON
val body = """{"status":401,"title":"Unauthorized","detail":"$message"}"""
val buffer = response.bufferFactory().wrap(body.toByteArray(Charsets.UTF_8))
return response.writeWith(Mono.just(buffer))
}
}

View File

@@ -0,0 +1,37 @@
server:
port: ${SERVER_PORT:8080}
spring:
application:
name: gateway
cloud:
gateway:
server:
webflux:
routes:
- id: identity
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
predicates:
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**
- id: academic
uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
predicates:
- Path=/api/v1/courses/**,/api/v1/articles/**,/api/v1/labs/**,/api/v1/assets/**
- id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates:
- Path=/api/v1/assignments/**,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute/**
management:
endpoints:
web:
exposure:
include: health,info,gateway
endpoint:
health:
probes:
enabled: true
lms:
jwt:
jwk-set-uri: ${LMS_JWT_JWK_SET_URI:http://localhost:8081/api/v1/auth/jwks.json}

View File

@@ -0,0 +1,31 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(libs.springBootStarterWeb)
implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator)
implementation(libs.springBootStarterDataJpa)
implementation(libs.springBootStarterSecurity)
implementation(libs.springBootStarterOauth2ResourceServer)
implementation(libs.springBootStarterFlyway)
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml)
implementation(libs.springdocStarterWebmvcUi)
implementation(libs.apachePoiOoxml)
implementation(project(":libs:common-http"))
implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support"))
testImplementation(libs.springBootStarterTest)
testImplementation(libs.springSecurityTest)
}
springBoot {
mainClass.set("ru.ysu.lms.identity.IdentityServiceApplicationKt")
}

View File

@@ -0,0 +1,14 @@
package ru.ysu.lms.identity
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
// The identity service manages its own JWT keys at runtime, so the generic
// auto-configuration from the shared library is excluded.
@SpringBootApplication(exclude = [LmsSecurityAutoConfiguration::class])
class IdentityServiceApplication
fun main(args: Array<String>) {
runApplication<IdentityServiceApplication>(*args)
}

View File

@@ -0,0 +1,50 @@
package ru.ysu.lms.identity.auth
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.RSAKey
import jakarta.validation.Valid
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping
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 java.util.UUID
@RestController
@RequestMapping("/api/v1/auth")
class AuthController(
private val authService: AuthService,
private val rsaKey: RSAKey,
) {
@GetMapping("/health")
fun health(): Map<String, Any> = mapOf(
"ok" to true,
"service" to "identity-service",
"adminExists" to authService.adminExists(),
)
@PostMapping("/setup")
fun setup(@Valid @RequestBody body: SetupRequest): AuthResponse = authService.setup(body)
@PostMapping("/login")
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body)
@GetMapping("/me")
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse =
authService.me(UUID.fromString(jwt.subject))
@PostMapping("/change-password")
fun changePassword(
@AuthenticationPrincipal jwt: Jwt,
@Valid @RequestBody body: ChangePasswordRequest,
): Map<String, Any> {
authService.changePassword(UUID.fromString(jwt.subject), body)
return mapOf("ok" to true)
}
@GetMapping("/jwks.json")
fun jwks(): Map<String, Any> = JWKSet(rsaKey).toPublicJWKSet().toJSONObject()
}

View File

@@ -0,0 +1,45 @@
package ru.ysu.lms.identity.auth
import jakarta.validation.constraints.NotBlank
data class SetupRequest(
@field:NotBlank(message = "Укажите логин")
val login: String,
@field:NotBlank(message = "Укажите пароль")
val password: String,
@field:NotBlank(message = "Повторите пароль")
val password2: String,
)
data class LoginRequest(
@field:NotBlank(message = "Укажите логин")
val login: String,
@field:NotBlank(message = "Укажите пароль")
val password: String,
)
data class ChangePasswordRequest(
@field:NotBlank(message = "Укажите текущий пароль")
val old: String,
@field:NotBlank(message = "Укажите новый пароль")
val new: String,
@field:NotBlank(message = "Повторите новый пароль")
val new2: String,
)
data class AuthResponse(
val token: String,
val id: String,
val login: String,
val role: String,
val name: String,
val mustChangePassword: Boolean,
)
data class MeResponse(
val id: String,
val login: String,
val role: String,
val name: String,
val mustChangePassword: Boolean,
)

View File

@@ -0,0 +1,133 @@
package ru.ysu.lms.identity.auth
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm
import org.springframework.security.oauth2.jwt.JwsHeader
import org.springframework.security.oauth2.jwt.JwtClaimsSet
import org.springframework.security.oauth2.jwt.JwtEncoder
import org.springframework.security.oauth2.jwt.JwtEncoderParameters
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
import ru.ysu.lms.security.LmsRole
import java.time.Instant
import java.util.UUID
@Service
class AuthService(
private val accounts: AccountRepository,
private val students: StudentRepository,
private val passwordEncoder: PasswordEncoder,
private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties,
) {
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
@Transactional
fun setup(request: SetupRequest): AuthResponse {
if (adminExists()) {
throw ConflictException("Аккаунт администратора уже существует")
}
val login = request.login.trim()
if (login.length < 3) {
throw BadRequestException("Логин: минимум 3 символа")
}
validatePassword(request.password, request.password2)
val account = Account(
login = login,
passwordHash = passwordEncoder.encode(request.password) ?: "",
role = LmsRole.ADMIN,
mustChangePassword = false,
)
accounts.save(account)
return toResponse(account, account.login)
}
@Transactional(readOnly = true)
fun login(request: LoginRequest): AuthResponse {
val account = accounts.findByLogin(request.login.trim())
?: throw UnauthorizedException("Неверный логин или пароль")
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
throw UnauthorizedException("Неверный логин или пароль")
}
return toResponse(account, displayName(account))
}
@Transactional(readOnly = true)
fun me(accountId: UUID): MeResponse {
val account = accounts.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
return MeResponse(
id = account.id.toString(),
login = account.login,
role = account.role.name,
name = displayName(account),
mustChangePassword = account.mustChangePassword,
)
}
@Transactional
fun changePassword(accountId: UUID, request: ChangePasswordRequest) {
val account = accounts.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
throw UnauthorizedException("Текущий пароль неверный")
}
validatePassword(request.new, request.new2)
account.passwordHash = passwordEncoder.encode(request.new) ?: account.passwordHash
account.mustChangePassword = false
account.tempPassword = null
account.updatedAt = Instant.now()
accounts.save(account)
}
private fun validatePassword(password: String, confirmation: String) {
if (password.length < 8) {
throw BadRequestException("Пароль: минимум 8 символов")
}
if (password != confirmation) {
throw BadRequestException("Пароли не совпадают")
}
}
private fun displayName(account: Account): String =
if (account.role == LmsRole.STUDENT) {
students.findById(account.id)
.map { it.fullName.ifBlank { account.login } }
.orElse(account.login)
} else {
account.login
}
private fun toResponse(account: Account, name: String): AuthResponse = AuthResponse(
token = issueToken(account, name),
id = account.id.toString(),
login = account.login,
role = account.role.name,
name = name,
mustChangePassword = account.mustChangePassword,
)
private fun issueToken(account: Account, name: String): String {
val now = Instant.now()
val claims = JwtClaimsSet.builder()
.issuer(jwtProperties.issuer)
.issuedAt(now)
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
.subject(account.id.toString())
.claim("role", account.role.name)
.claim("login", account.login)
.claim("name", name)
.build()
val header = JwsHeader.with(SignatureAlgorithm.RS256).build()
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
}
}

View File

@@ -0,0 +1,13 @@
package ru.ysu.lms.identity.auth
import java.security.SecureRandom
object Passwords {
private const val ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
private val random = SecureRandom()
fun generate(length: Int = 10): String =
buildString(length) {
repeat(length) { append(ALPHABET[random.nextInt(ALPHABET.length)]) }
}
}

View File

@@ -0,0 +1,41 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import ru.ysu.lms.security.LmsRole
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "accounts")
class Account(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 64)
var login: String = "",
@Column(name = "password_hash", nullable = false)
var passwordHash: String = "",
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var role: LmsRole = LmsRole.STUDENT,
@Column(name = "must_change_password", nullable = false)
var mustChangePassword: Boolean = true,
@Column(name = "temp_password", length = 64)
var tempPassword: String? = null,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,30 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "enrollments")
class Enrollment(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "group_id", nullable = false)
var group: GroupEntity? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "student_id", nullable = false)
var student: Student? = null,
@Column(name = "enrolled_at", nullable = false)
var enrolledAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,25 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "groups")
class GroupEntity(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 100)
var name: String = "",
@Column(name = "task_duration_min", nullable = false)
var taskDurationMin: Int = 30,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,17 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
@Entity
@Table(name = "settings")
class Setting(
@Id
@Column(name = "key", length = 100)
var key: String = "",
@Column(name = "value", nullable = false)
var value: String = "",
)

View File

@@ -0,0 +1,47 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.MapsId
import jakarta.persistence.OneToOne
import jakarta.persistence.Table
import java.util.UUID
@Entity
@Table(name = "students")
class Student(
@Id
@Column(name = "account_id")
var accountId: UUID? = null,
@Column(name = "student_no", nullable = false, unique = true, length = 32)
var studentNo: String = "",
@Column(name = "last_name", nullable = false, length = 100)
var lastName: String = "",
@Column(name = "first_name", nullable = false, length = 100)
var firstName: String = "",
@Column(name = "middle_name", nullable = false, length = 100)
var middleName: String = "",
@Column(name = "basis", nullable = false, length = 100)
var basis: String = "",
@Column(name = "note", nullable = false)
var note: String = "",
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "account_id")
var account: Account? = null,
) {
val fullName: String
get() = listOf(lastName, firstName, middleName)
.filter { it.isNotBlank() }
.joinToString(" ")
}

View File

@@ -0,0 +1,13 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.security.LmsRole
import java.util.UUID
interface AccountRepository : JpaRepository<Account, UUID> {
fun findByLogin(login: String): Account?
fun existsByLogin(login: String): Boolean
fun existsByRole(role: LmsRole): Boolean
fun countByRole(role: LmsRole): Long
}

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import ru.ysu.lms.identity.domain.Enrollment
import java.util.UUID
interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
@Query(
"select e from Enrollment e join fetch e.student s join fetch s.account where e.group.id = :groupId",
)
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment>
fun findByStudent_AccountId(accountId: UUID): Enrollment?
fun deleteByStudent_AccountId(accountId: UUID)
fun countByGroup_Id(groupId: UUID): Long
}

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.GroupEntity
import java.util.UUID
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun findByName(name: String): GroupEntity?
fun existsByName(name: String): Boolean
fun findAllByOrderByNameAsc(): List<GroupEntity>
}

View File

@@ -0,0 +1,6 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.Setting
interface SettingRepository : JpaRepository<Setting, String>

View File

@@ -0,0 +1,10 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.Student
import java.util.UUID
interface StudentRepository : JpaRepository<Student, UUID> {
fun findByStudentNo(studentNo: String): Student?
fun existsByStudentNo(studentNo: String): Boolean
}

View File

@@ -0,0 +1,53 @@
package ru.ysu.lms.identity.roster
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.ByteArrayOutputStream
object CredentialsExporter {
private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус")
fun csv(rows: List<StudentRow>): ByteArray {
val builder = StringBuilder()
builder.append(HEADERS.joinToString(";")).append("\r\n")
rows.forEach { row ->
builder.append(
listOf(
row.fullName,
row.studentNo,
row.tempPassword ?: "—",
status(row),
).joinToString(";") { escapeCsv(it) },
).append("\r\n")
}
return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8)
}
fun xlsx(rows: List<StudentRow>): ByteArray {
XSSFWorkbook().use { workbook ->
val sheet = workbook.createSheet("Логины")
val header = sheet.createRow(0)
HEADERS.forEachIndexed { index, title -> header.createCell(index).setCellValue(title) }
rows.forEachIndexed { index, row ->
val cells = sheet.createRow(index + 1)
cells.createCell(0).setCellValue(row.fullName)
cells.createCell(1).setCellValue(row.studentNo)
cells.createCell(2).setCellValue(row.tempPassword ?: "—")
cells.createCell(3).setCellValue(status(row))
}
val out = ByteArrayOutputStream()
workbook.write(out)
return out.toByteArray()
}
}
private fun status(row: StudentRow): String =
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
private fun escapeCsv(value: String): String =
if (value.contains(';') || value.contains('"') || value.contains('\n')) {
"\"" + value.replace("\"", "\"\"") + "\""
} else {
value
}
}

View File

@@ -0,0 +1,58 @@
package ru.ysu.lms.identity.roster
import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.common.BadRequestException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/v1/groups")
@PreAuthorize("hasRole('ADMIN')")
class GroupController(private val groupService: GroupService) {
@GetMapping
fun list(): List<GroupSummary> = groupService.list()
@PostMapping
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,
@RequestParam(name = "name", required = false) name: String?,
): ImportResult {
if (file.isEmpty) {
throw BadRequestException("Выберите файл со списком группы")
}
return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
}
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
@GetMapping("/{id}/credentials")
fun credentials(
@PathVariable id: UUID,
@RequestParam(defaultValue = "xlsx") format: String,
): ResponseEntity<ByteArray> {
val export = groupService.credentials(id, format)
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
.contentType(MediaType.parseMediaType(export.contentType))
.body(export.bytes)
}
}

View File

@@ -0,0 +1,175 @@
package ru.ysu.lms.identity.roster
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.identity.auth.Passwords
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.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
@Service
class GroupService(
private val groups: GroupRepository,
private val enrollments: EnrollmentRepository,
private val students: StudentRepository,
private val accounts: AccountRepository,
private val passwordEncoder: PasswordEncoder,
private val parser: StudentImportParser,
) {
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
@Transactional
fun create(request: CreateGroupRequest): GroupSummary {
val name = request.name.trim()
if (groups.existsByName(name)) {
throw ConflictException("Группа «$name» уже существует")
}
return summary(groups.save(GroupEntity(name = name)))
}
@Transactional
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
val parsed = parser.parse(filename, bytes)
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
val added = mutableListOf<Credential>()
val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent ->
val existing = students.findByStudentNo(parsedStudent.studentNo)
val student = if (existing != null) {
existing.lastName = parsedStudent.lastName
existing.firstName = parsedStudent.firstName
existing.middleName = parsedStudent.middleName
existing.basis = parsedStudent.basis
existing.note = parsedStudent.note
students.save(existing)
updated.add(parsedStudent.studentNo)
existing
} else {
val password = Passwords.generate()
val account = accounts.save(
Account(
login = parsedStudent.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created = Student(
studentNo = parsedStudent.studentNo,
lastName = parsedStudent.lastName,
firstName = parsedStudent.firstName,
middleName = parsedStudent.middleName,
basis = parsedStudent.basis,
note = parsedStudent.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsedStudent.studentNo,
fullName = parsedStudent.fullName,
password = password,
),
)
created
}
val accountId = student.accountId!!
val enrollment = enrollments.findByStudent_AccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
}
return ImportResult(group.id.toString(), group.name, added, updated)
}
@Transactional(readOnly = true)
fun detail(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val rows = enrollments.findAllByGroupId(groupId).map { enrollment ->
val student = enrollment.student!!
val account = student.account!!
StudentRow(
id = account.id.toString(),
studentNo = student.studentNo,
fullName = student.fullName,
basis = student.basis,
mustChangePassword = account.mustChangePassword,
tempPassword = account.tempPassword,
)
}.sortedWith(Comparator { a, b -> studentNoComparator.compare(a.studentNo, b.studentNo) })
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
}
@Transactional
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
val student = students.findById(studentAccountId)
.orElseThrow { NotFoundException("Студент не найден") }
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
val password = Passwords.generate()
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
account.mustChangePassword = true
account.tempPassword = password
accounts.save(account)
return ResetPasswordResponse(password)
}
@Transactional(readOnly = true)
fun credentials(groupId: UUID, format: String): ExportFile {
val detail = detail(groupId)
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
return when (format.lowercase()) {
"csv" -> ExportFile(
bytes = CredentialsExporter.csv(detail.students),
filename = "credentials_$safeName.csv",
contentType = "text/csv; charset=utf-8",
)
"xlsx" -> ExportFile(
bytes = CredentialsExporter.xlsx(detail.students),
filename = "credentials_$safeName.xlsx",
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
else -> throw BadRequestException("Формат должен быть xlsx или csv")
}
}
private fun summary(group: GroupEntity): GroupSummary = GroupSummary(
id = group.id.toString(),
name = group.name,
studentCount = enrollments.countByGroup_Id(group.id),
taskDurationMin = group.taskDurationMin,
)
private companion object {
val studentNoComparator = Comparator<String> { a, b ->
val left = a.toIntOrNull()
val right = b.toIntOrNull()
when {
left != null && right != null -> left.compareTo(right)
left != null -> -1
right != null -> 1
else -> a.compareTo(b)
}
}
}
}

View File

@@ -0,0 +1,55 @@
package ru.ysu.lms.identity.roster
import jakarta.validation.constraints.NotBlank
data class GroupSummary(
val id: String,
val name: String,
val studentCount: Long,
val taskDurationMin: Int,
)
data class CreateGroupRequest(
@field:NotBlank(message = "Укажите название группы")
val name: String,
)
data class StudentRow(
val id: String,
val studentNo: String,
val fullName: String,
val basis: String,
val mustChangePassword: Boolean,
val tempPassword: String?,
)
data class GroupDetail(
val id: String,
val name: String,
val taskDurationMin: Int,
val students: List<StudentRow>,
)
data class Credential(
val id: String,
val studentNo: String,
val fullName: String,
val password: String,
)
data class ImportResult(
val groupId: String,
val groupName: String,
val added: List<Credential>,
val updated: List<String>,
)
data class ResetPasswordResponse(
val password: String,
)
class ExportFile(
val bytes: ByteArray,
val filename: String,
val contentType: String,
)

View File

@@ -0,0 +1,18 @@
package ru.ysu.lms.identity.roster
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/v1/students")
@PreAuthorize("hasRole('ADMIN')")
class StudentController(private val groupService: GroupService) {
@PostMapping("/{id}/reset-password")
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse =
groupService.resetPassword(id)
}

View File

@@ -0,0 +1,160 @@
package ru.ysu.lms.identity.roster
import org.apache.poi.ss.usermodel.DataFormatter
import org.apache.poi.ss.usermodel.WorkbookFactory
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
data class ParsedStudent(
val studentNo: String,
val lastName: String,
val firstName: String,
val middleName: String,
val basis: String,
val note: String,
) {
val fullName: String
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
}
data class ParsedGroup(
val groupName: String,
val students: List<ParsedStudent>,
)
@Component
class StudentImportParser {
fun parse(filename: String, bytes: ByteArray): ParsedGroup {
val lower = filename.lowercase()
return when {
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseRows(filename, parseExcel(bytes))
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
}
}
private fun parseExcel(bytes: ByteArray): List<List<String>> {
WorkbookFactory.create(ByteArrayInputStream(bytes)).use { workbook ->
val sheet = workbook.getSheetAt(0)
val formatter = DataFormatter()
return (0..sheet.lastRowNum).map { rowIndex ->
val row = sheet.getRow(rowIndex) ?: return@map emptyList()
val cells = mutableListOf<String>()
for (col in 0 until row.lastCellNum.toInt()) {
cells.add(formatter.formatCellValue(row.getCell(col)).trim())
}
cells
}
}
}
private fun parseCsv(bytes: ByteArray): List<List<String>> {
val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF")
val lines = text.split(Regex("\r?\n")).filter { it.isNotBlank() }
if (lines.isEmpty()) {
throw BadRequestException("Файл пуст")
}
val delimiter = detectDelimiter(lines.first())
return lines.map { splitCsvLine(it, delimiter) }
}
private fun detectDelimiter(header: String): Char {
val candidates = listOf(';', ',', '\t')
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
}
private fun splitCsvLine(line: String, delimiter: Char): List<String> {
val result = mutableListOf<String>()
val current = StringBuilder()
var inQuotes = false
for (ch in line) {
when {
ch == '"' -> inQuotes = !inQuotes
ch == delimiter && !inQuotes -> {
result.add(current.toString().trim())
current.clear()
}
else -> current.append(ch)
}
}
result.add(current.toString().trim())
return result
}
private fun parseRows(filename: String, rows: List<List<String>>): ParsedGroup {
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
if (headerIndex < 0) {
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
}
val columns = mapColumns(rows[headerIndex])
if (columns["studentNo"] == null) {
throw BadRequestException("Не найден столбец «Студенческий номер»")
}
val students = rows.drop(headerIndex + 1).mapNotNull { row ->
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
val studentNo = normalizeNumber(cell("studentNo"))
val lastName = cell("lastName")
if (studentNo.isBlank() && lastName.isBlank()) {
return@mapNotNull null
}
if (studentNo.isBlank()) {
throw BadRequestException("В одной из строк отсутствует студенческий номер")
}
ParsedStudent(
studentNo = studentNo,
lastName = lastName,
firstName = cell("firstName"),
middleName = cell("middleName"),
basis = cell("basis"),
note = cell("note"),
)
}
if (students.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента")
}
return ParsedGroup(groupNameFrom(filename, rows), students)
}
private fun isStudentHeader(value: String): Boolean {
val normalized = value.lowercase().replace("\u00a0", " ").trim()
return normalized.startsWith("студен") || normalized.startsWith("студ.") ||
normalized.startsWith("зачёт") || normalized.startsWith("зачет")
}
private fun mapColumns(header: List<String>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
header.forEachIndexed { index, raw ->
val value = raw.lowercase().replace("\u00a0", " ").trim()
fun putIfAbsent(key: String, vararg prefixes: String) {
if (key !in result && prefixes.any { value.startsWith(it) }) {
result[key] = index
}
}
putIfAbsent("studentNo", "студенч", "студ.", "студбил", "зачёт", "зачет")
putIfAbsent("lastName", "фамили")
putIfAbsent("firstName", "имя")
putIfAbsent("middleName", "отчеств")
putIfAbsent("basis", "основ")
putIfAbsent("note", "примеч")
}
return result
}
private fun normalizeNumber(value: String): String =
if (value.endsWith(".0")) value.dropLast(2) else value
private fun groupNameFrom(filename: String, rows: List<List<String>>): String {
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
for (row in rows.take(12)) {
for (cell in row) {
val value = cell.trim()
if (value.length in 2..30 && groupRegex.matches(value)) {
return value
}
}
}
return filename.substringBeforeLast('.').trim().ifBlank { "Группа" }
}
}

View File

@@ -0,0 +1,37 @@
package ru.ysu.lms.identity.security
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
import com.nimbusds.jose.proc.SecurityContext
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.JwtEncoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
import ru.ysu.lms.security.JwtProperties
@Configuration
@EnableConfigurationProperties(JwtProperties::class)
class JwtConfig(private val keyStore: JwtKeyStore) {
@Bean
fun rsaKey(): RSAKey = RSAKey.Builder(keyStore.publicKey)
.privateKey(keyStore.privateKey)
.keyID("lms")
.build()
@Bean
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder =
NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
@Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}

View File

@@ -0,0 +1,50 @@
package ru.ysu.lms.identity.security
import jakarta.annotation.PostConstruct
import org.springframework.stereotype.Service
import ru.ysu.lms.identity.domain.Setting
import ru.ysu.lms.identity.repo.SettingRepository
import ru.ysu.lms.security.RsaKeys
import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.util.Base64
/**
* RSA signing keys for the identity service. Generated on first start and kept
* in the `settings` table, so no private key ever lives in the repository.
*/
@Service
class JwtKeyStore(private val settings: SettingRepository) {
lateinit var privateKey: RSAPrivateKey
lateinit var publicKey: RSAPublicKey
@PostConstruct
fun init() {
val storedPrivate = settings.findById(PRIVATE_KEY).map { it.value }.orElse(null)
val storedPublic = settings.findById(PUBLIC_KEY).map { it.value }.orElse(null)
if (storedPrivate != null && storedPublic != null) {
privateKey = RsaKeys.privateKey(storedPrivate)
publicKey = RsaKeys.publicKey(storedPublic)
} else {
val generator = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }
val pair = generator.generateKeyPair()
privateKey = pair.private as RSAPrivateKey
publicKey = pair.public as RSAPublicKey
settings.save(Setting(PRIVATE_KEY, toPem("PRIVATE KEY", privateKey.encoded)))
settings.save(Setting(PUBLIC_KEY, toPem("PUBLIC KEY", publicKey.encoded)))
}
}
private fun toPem(label: String, der: ByteArray): String {
val encoded = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(der)
return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n"
}
companion object {
const val PRIVATE_KEY = "jwt_private_key"
const val PUBLIC_KEY = "jwt_public_key"
}
}

View File

@@ -0,0 +1,62 @@
package ru.ysu.lms.identity.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers(
"/api/v1/auth/health",
"/api/v1/auth/setup",
"/api/v1/auth/login",
"/api/v1/auth/jwks.json",
"/actuator/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
).permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { resource ->
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
}
return http.build()
}
private fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter(roleAuthoritiesConverter())
return converter
}
private fun roleAuthoritiesConverter(): Converter<Jwt, Collection<GrantedAuthority>> =
Converter { jwt ->
val role = jwt.getClaimAsString("role").orEmpty()
if (role.isBlank()) {
emptyList()
} else {
listOf(SimpleGrantedAuthority("ROLE_$role"))
}
}
}

View File

@@ -0,0 +1,37 @@
server:
port: ${SERVER_PORT:8081}
spring:
application:
name: identity-service
datasource:
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=identity}
username: ${LMS_DB_USER:lms}
password: ${LMS_DB_PASSWORD:lms}
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: identity
flyway:
enabled: true
locations: classpath:db/migration
schemas: identity
default-schema: identity
create-schemas: true
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs:
path: /v3/api-docs

View File

@@ -0,0 +1,2 @@
-- Baseline for the identity schema (accounts, students, groups, enrollments).
CREATE SCHEMA IF NOT EXISTS identity;

View File

@@ -0,0 +1,45 @@
-- Accounts, students, groups and enrollments for the identity service.
CREATE TABLE accounts (
id uuid PRIMARY KEY DEFAULT uuidv7(),
login varchar(64) NOT NULL UNIQUE,
password_hash varchar(255) NOT NULL,
role varchar(16) NOT NULL,
must_change_password boolean NOT NULL DEFAULT true,
temp_password varchar(64),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT accounts_role_check CHECK (role IN ('ADMIN', 'STUDENT'))
);
CREATE TABLE students (
account_id uuid PRIMARY KEY REFERENCES accounts (id) ON DELETE CASCADE,
student_no varchar(32) NOT NULL UNIQUE,
last_name varchar(100) NOT NULL DEFAULT '',
first_name varchar(100) NOT NULL DEFAULT '',
middle_name varchar(100) NOT NULL DEFAULT '',
basis varchar(100) NOT NULL DEFAULT '',
note text NOT NULL DEFAULT ''
);
CREATE TABLE groups (
id uuid PRIMARY KEY DEFAULT uuidv7(),
name varchar(100) NOT NULL UNIQUE,
task_duration_min integer NOT NULL DEFAULT 30,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE enrollments (
id uuid PRIMARY KEY DEFAULT uuidv7(),
group_id uuid NOT NULL REFERENCES groups (id) ON DELETE CASCADE,
student_id uuid NOT NULL REFERENCES students (account_id) ON DELETE CASCADE,
enrolled_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT enrollments_student_unique UNIQUE (student_id)
);
CREATE INDEX enrollments_group_idx ON enrollments (group_id);
CREATE TABLE settings (
key varchar(100) PRIMARY KEY,
value text NOT NULL
);

View File

@@ -0,0 +1,62 @@
package ru.ysu.lms.identity
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.common.UnauthorizedException
@SpringBootTest
@Testcontainers
class IdentitySmokeTest {
companion object {
@Container
val pg = PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun datasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
}
}
@Autowired
lateinit var authService: AuthService
@Test
fun `context loads and admin not exists`() {
assert(!authService.adminExists()) { "admin should not exist" }
}
@Test
fun `setup creates admin and login works`() {
val token = authService.setup(
ru.ysu.lms.identity.auth.SetupRequest("admin", "test12345", "test12345")
)
assert(token.token.isNotBlank()) { "token should not be blank" }
assert(token.role == "ADMIN")
val login = authService.login(
ru.ysu.lms.identity.auth.LoginRequest("admin", "test12345")
)
assert(login.token.isNotBlank())
val ex = try {
authService.login(ru.ysu.lms.identity.auth.LoginRequest("admin", "bad"))
null
} catch (e: UnauthorizedException) { e }
assert(ex != null) { "bad password should throw" }
}
}

View File

@@ -0,0 +1,26 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(libs.springBootStarterWeb)
implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator)
implementation(libs.springBootStarterDataJpa)
implementation(libs.springBootStarterSecurity)
implementation(libs.springBootStarterOauth2ResourceServer)
implementation(libs.springBootStarterFlyway)
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.springdocStarterWebmvcUi)
implementation(project(":libs:common-http"))
implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support"))
}
springBoot {
mainClass.set("ru.ysu.lms.progress.ProgressServiceApplicationKt")
}

View File

@@ -0,0 +1,12 @@
package ru.ysu.lms.progress
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
@SpringBootApplication
class ProgressServiceApplication
fun main(args: Array<String>) {
runApplication<ProgressServiceApplication>(*args)
}

View File

@@ -0,0 +1,33 @@
package ru.ysu.lms.progress.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "variant_assignments")
class VariantAssignment(
@Id var id: UUID = Ids.v7(),
@Column(nullable = false) var studentId: UUID = UUID(0,0),
@Column(nullable = false) var labId: UUID = UUID(0,0),
@Column(nullable = false) var variantId: UUID = UUID(0,0),
@Column(nullable = false) var manual: Boolean = false,
@Column(nullable = false) var assignedAt: Instant = Instant.now(),
)
@Entity
@Table(name = "student_lab_levels")
class StudentLabLevel(
@Id
@Column(name = "student_id", insertable = false, updatable = false)
var studentId: UUID = UUID(0,0),
@Id
@Column(name = "lab_id", insertable = false, updatable = false)
var labId: UUID = UUID(0,0),
@Column(nullable = false) var labLevelId: UUID = UUID(0,0),
@Column(nullable = false) var manual: Boolean = false,
)

View File

@@ -0,0 +1,38 @@
package ru.ysu.lms.progress.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { it.jwt { } }
return http.build()
}
companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
}
}

View File

@@ -0,0 +1,30 @@
server:
port: ${SERVER_PORT:8083}
spring:
application:
name: progress-service
datasource:
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=progress}
username: ${LMS_DB_USER:lms}
password: ${LMS_DB_PASSWORD:lms}
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: progress
flyway:
enabled: true
locations: classpath:db/migration
schemas: progress
default-schema: progress
create-schemas: true
management:
endpoints:
web:
exposure:
include: health,info
springdoc:
api-docs:
path: /v3/api-docs

View File

@@ -0,0 +1,2 @@
-- Baseline: student lab levels and variant assignments.
CREATE SCHEMA IF NOT EXISTS progress;

View File

@@ -0,0 +1,22 @@
-- Student levels per lab and variant assignments.
CREATE TABLE student_lab_levels (
student_id uuid NOT NULL,
lab_id uuid NOT NULL,
lab_level_id uuid NOT NULL,
manual boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (student_id, lab_id)
);
CREATE TABLE variant_assignments (
id uuid PRIMARY KEY DEFAULT uuidv7(),
student_id uuid NOT NULL,
lab_id uuid NOT NULL,
variant_id uuid NOT NULL,
manual boolean NOT NULL DEFAULT false,
assigned_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT va_student_lab_unique UNIQUE (student_id, lab_id)
);
CREATE INDEX va_lab_idx ON variant_assignments (lab_id);