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,11 @@
plugins {
id("lms.spring-library")
}
dependencies {
implementation(platform(libs.springBootBom))
api(libs.springContext)
api(libs.springBootAutoconfigure)
api(libs.springSecurityOauth2Jose)
}

View File

@@ -0,0 +1,15 @@
package ru.ysu.lms.security
enum class LmsRole {
ADMIN,
STUDENT;
fun authority(): String = "ROLE_$name"
}
data class JwtPrincipal(
val accountId: String,
val login: String,
val role: LmsRole,
val displayName: String? = null,
)

View File

@@ -0,0 +1,16 @@
package ru.ysu.lms.security
import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration
@ConfigurationProperties(prefix = "lms.jwt")
data class JwtProperties(
val issuer: String = "lms",
/** PKCS#8 PEM private key. Empty disables the encoder. */
val privateKey: String = "",
/** X.509 PEM public key. Empty disables the decoder. */
val publicKey: String = "",
/** JWKS endpoint used to build the decoder when [publicKey] is empty. */
val jwkSetUri: String = "",
val accessTokenTtl: Duration = Duration.ofHours(12),
)

View File

@@ -0,0 +1,48 @@
package ru.ysu.lms.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.autoconfigure.AutoConfiguration
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
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
@AutoConfiguration
@EnableConfigurationProperties(JwtProperties::class)
class LmsSecurityAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"])
fun jwtEncoder(properties: JwtProperties): JwtEncoder {
val rsaKey = RSAKey.Builder(RsaKeys.publicKey(properties.publicKey))
.privateKey(RsaKeys.privateKey(properties.privateKey))
.build()
return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
}
@Bean
@ConditionalOnMissingBean
fun jwtDecoder(properties: JwtProperties): JwtDecoder =
if (properties.jwkSetUri.isNotBlank()) {
NimbusJwtDecoder.withJwkSetUri(properties.jwkSetUri).build()
} else {
require(properties.publicKey.isNotBlank()) {
"Configure either lms.jwt.jwk-set-uri or lms.jwt.public-key"
}
NimbusJwtDecoder.withPublicKey(RsaKeys.publicKey(properties.publicKey)).build()
}
@Bean
@ConditionalOnMissingBean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}

View File

@@ -0,0 +1,32 @@
package ru.ysu.lms.security
import java.security.KeyFactory
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
/** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */
object RsaKeys {
fun publicKey(pem: String): RSAPublicKey {
val der = decode(pem)
return KeyFactory.getInstance("RSA")
.generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey
}
fun privateKey(pem: String): RSAPrivateKey {
val der = decode(pem)
return KeyFactory.getInstance("RSA")
.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
}
private fun decode(pem: String): ByteArray {
val body = pem
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
.replace(Regex("-----END [A-Z ]+-----"), "")
.replace(Regex("\\s"), "")
return Base64.getDecoder().decode(body)
}
}

View File

@@ -0,0 +1 @@
ru.ysu.lms.security.LmsSecurityAutoConfiguration