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:
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)]) }
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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(" ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 { "Группа" }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
37
services/identity-service/src/main/resources/application.yml
Normal file
37
services/identity-service/src/main/resources/application.yml
Normal 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
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Baseline for the identity schema (accounts, students, groups, enrollments).
|
||||
CREATE SCHEMA IF NOT EXISTS identity;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user