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,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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
||||
Reference in New Issue
Block a user