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,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" }
}
}