Ajout de fichiers ignorés par erreur
This commit is contained in:
parent
3db4bbb0ee
commit
0d08c78056
|
@ -8,7 +8,6 @@
|
||||||
gradle/
|
gradle/
|
||||||
build/
|
build/
|
||||||
logs/
|
logs/
|
||||||
config/
|
|
||||||
data/
|
data/
|
||||||
dokka/
|
dokka/
|
||||||
dist/
|
dist/
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
package dev.fyloz.colorrecipesexplorer.config
|
||||||
|
|
||||||
|
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
|
||||||
|
import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties
|
||||||
|
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
||||||
|
import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES
|
||||||
|
import dev.fyloz.colorrecipesexplorer.restartApplication
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
|
import org.slf4j.Logger
|
||||||
|
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
|
import org.springframework.context.ApplicationListener
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.context.annotation.Profile
|
||||||
|
import org.springframework.core.Ordered
|
||||||
|
import org.springframework.core.annotation.Order
|
||||||
|
import javax.annotation.PostConstruct
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
@Profile("!emergency")
|
||||||
|
class ApplicationReadyListener(
|
||||||
|
private val materialTypeService: MaterialTypeService,
|
||||||
|
private val configurationService: ConfigurationService,
|
||||||
|
private val materialTypeProperties: MaterialTypeProperties,
|
||||||
|
private val creProperties: CreProperties,
|
||||||
|
private val logger: Logger
|
||||||
|
) : ApplicationListener<ApplicationReadyEvent> {
|
||||||
|
override fun onApplicationEvent(event: ApplicationReadyEvent) {
|
||||||
|
if (emergencyMode) {
|
||||||
|
logger.error("Emergency mode is enabled, default material types will not be created")
|
||||||
|
thread {
|
||||||
|
Thread.sleep(1000)
|
||||||
|
logger.warn("Restarting in emergency mode...")
|
||||||
|
restartApplication(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
initDatabaseConfigurations()
|
||||||
|
initMaterialTypes()
|
||||||
|
CRE_PROPERTIES = creProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initMaterialTypes() {
|
||||||
|
logger.info("Initializing system material types")
|
||||||
|
materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initDatabaseConfigurations() {
|
||||||
|
configurationService.initializeProperties { !it.file }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration("configurationsInitializer")
|
||||||
|
class ConfigurationsInitializer(
|
||||||
|
private val configurationService: ConfigurationService
|
||||||
|
) {
|
||||||
|
@PostConstruct
|
||||||
|
fun initializeFileConfigurations() {
|
||||||
|
configurationService.initializeProperties { it.file }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApplicationInitializer : ApplicationListener<ApplicationEnvironmentPreparedEvent> {
|
||||||
|
override fun onApplicationEvent(event: ApplicationEnvironmentPreparedEvent) {
|
||||||
|
if (emergencyMode) {
|
||||||
|
event.environment.setActiveProfiles("emergency")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
package dev.fyloz.colorrecipesexplorer.config.security
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||||
|
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
||||||
|
import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest
|
||||||
|
import io.jsonwebtoken.ExpiredJwtException
|
||||||
|
import io.jsonwebtoken.Jwts
|
||||||
|
import io.jsonwebtoken.SignatureAlgorithm
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.Authentication
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.security.core.userdetails.User
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||||
|
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
|
||||||
|
import org.springframework.util.Assert
|
||||||
|
import org.springframework.web.util.WebUtils
|
||||||
|
import java.util.*
|
||||||
|
import javax.servlet.FilterChain
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
const val authorizationCookieName = "Authorization"
|
||||||
|
const val defaultGroupCookieName = "Default-Group"
|
||||||
|
val blacklistedJwtTokens = mutableListOf<String>()
|
||||||
|
|
||||||
|
class JwtAuthenticationFilter(
|
||||||
|
private val authManager: AuthenticationManager,
|
||||||
|
private val securityConfigurationProperties: CreSecurityProperties,
|
||||||
|
private val updateUserLoginTime: (Long) -> Unit
|
||||||
|
) : UsernamePasswordAuthenticationFilter() {
|
||||||
|
private var debugMode = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
setFilterProcessesUrl("/api/login")
|
||||||
|
debugMode = "debug" in environment.activeProfiles
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
|
||||||
|
val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequest::class.java)
|
||||||
|
return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun successfulAuthentication(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
chain: FilterChain,
|
||||||
|
authResult: Authentication
|
||||||
|
) {
|
||||||
|
val jwtSecret = securityConfigurationProperties.jwtSecret
|
||||||
|
val jwtDuration = securityConfigurationProperties.jwtDuration
|
||||||
|
Assert.notNull(jwtSecret, "No JWT secret has been defined.")
|
||||||
|
Assert.notNull(jwtDuration, "No JWT duration has been defined.")
|
||||||
|
val userId = (authResult.principal as User).username
|
||||||
|
updateUserLoginTime(userId.toLong())
|
||||||
|
val expirationMs = System.currentTimeMillis() + jwtDuration
|
||||||
|
val expirationDate = Date(expirationMs)
|
||||||
|
val token = Jwts.builder()
|
||||||
|
.setSubject(userId)
|
||||||
|
.setExpiration(expirationDate)
|
||||||
|
.signWith(SignatureAlgorithm.HS512, jwtSecret.toByteArray())
|
||||||
|
.compact()
|
||||||
|
response.addHeader("Access-Control-Expose-Headers", "X-Authentication-Expiration")
|
||||||
|
var bearerCookie =
|
||||||
|
"$authorizationCookieName=Bearer$token; Max-Age=${jwtDuration / 1000}; HttpOnly; SameSite=strict"
|
||||||
|
if (!debugMode) bearerCookie += "; Secure;"
|
||||||
|
response.addHeader(
|
||||||
|
"Set-Cookie",
|
||||||
|
bearerCookie
|
||||||
|
)
|
||||||
|
response.addHeader(authorizationCookieName, "Bearer $token")
|
||||||
|
response.addHeader("X-Authentication-Expiration", "$expirationMs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JwtAuthorizationFilter(
|
||||||
|
private val securityConfigurationProperties: CreSecurityProperties,
|
||||||
|
authenticationManager: AuthenticationManager,
|
||||||
|
private val loadUserById: (Long) -> UserDetails
|
||||||
|
) : BasicAuthenticationFilter(authenticationManager) {
|
||||||
|
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
|
||||||
|
fun tryLoginFromBearer(): Boolean {
|
||||||
|
val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName)
|
||||||
|
// Check for an authorization token cookie or header
|
||||||
|
val authorizationToken = if (authorizationCookie != null)
|
||||||
|
authorizationCookie.value
|
||||||
|
else
|
||||||
|
request.getHeader(authorizationCookieName)
|
||||||
|
|
||||||
|
// An authorization token is valid if it starts with "Bearer", is not expired and is not blacklisted
|
||||||
|
if (authorizationToken != null && authorizationToken.startsWith("Bearer") && authorizationToken !in blacklistedJwtTokens) {
|
||||||
|
val authenticationToken = getAuthentication(authorizationToken) ?: return false
|
||||||
|
SecurityContextHolder.getContext().authentication = authenticationToken
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryLoginFromDefaultGroupCookie() {
|
||||||
|
val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName)
|
||||||
|
if (defaultGroupCookie != null) {
|
||||||
|
val authenticationToken = getAuthenticationToken(defaultGroupCookie.value)
|
||||||
|
SecurityContextHolder.getContext().authentication = authenticationToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tryLoginFromBearer())
|
||||||
|
tryLoginFromDefaultGroupCookie()
|
||||||
|
chain.doFilter(request, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? {
|
||||||
|
val jwtSecret = securityConfigurationProperties.jwtSecret
|
||||||
|
Assert.notNull(jwtSecret, "No JWT secret has been defined.")
|
||||||
|
return try {
|
||||||
|
val userId = Jwts.parser()
|
||||||
|
.setSigningKey(jwtSecret.toByteArray())
|
||||||
|
.parseClaimsJws(token.replace("Bearer", ""))
|
||||||
|
.body
|
||||||
|
.subject
|
||||||
|
if (userId != null) getAuthenticationToken(userId) else null
|
||||||
|
} catch (_: ExpiredJwtException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAuthenticationToken(userId: String): UsernamePasswordAuthenticationToken? = try {
|
||||||
|
val userDetails = loadUserById(userId.toLong())
|
||||||
|
UsernamePasswordAuthenticationToken(userDetails.username, null, userDetails.authorities)
|
||||||
|
} catch (_: NotFoundException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,239 @@
|
||||||
|
package dev.fyloz.colorrecipesexplorer.config.security
|
||||||
|
|
||||||
|
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||||
|
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
||||||
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
|
import dev.fyloz.colorrecipesexplorer.model.account.User
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.CreUserDetailsService
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.UserService
|
||||||
|
import org.slf4j.Logger
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||||
|
import org.springframework.context.annotation.*
|
||||||
|
import org.springframework.core.env.Environment
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
|
import org.springframework.security.core.AuthenticationException
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.util.Assert
|
||||||
|
import org.springframework.web.cors.CorsConfiguration
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
||||||
|
import javax.annotation.PostConstruct
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
import org.springframework.security.core.userdetails.User as SpringUser
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@Profile("!emergency")
|
||||||
|
@EnableWebSecurity
|
||||||
|
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||||
|
@EnableConfigurationProperties(CreSecurityProperties::class)
|
||||||
|
class SecurityConfig(
|
||||||
|
private val securityProperties: CreSecurityProperties,
|
||||||
|
@Lazy private val userDetailsService: CreUserDetailsService,
|
||||||
|
@Lazy private val userService: UserService,
|
||||||
|
private val environment: Environment,
|
||||||
|
private val logger: Logger
|
||||||
|
) : WebSecurityConfigurerAdapter() {
|
||||||
|
var debugMode = false
|
||||||
|
|
||||||
|
override fun configure(authBuilder: AuthenticationManagerBuilder) {
|
||||||
|
authBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun passwordEncoder() =
|
||||||
|
getPasswordEncoder()
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun corsConfigurationSource() =
|
||||||
|
getCorsConfigurationSource()
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
fun initWebSecurity() {
|
||||||
|
if (emergencyMode) {
|
||||||
|
logger.error("Emergency mode is enabled, system users will not be created")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
debugMode = "debug" in environment.activeProfiles
|
||||||
|
if (debugMode) logger.warn("Debug mode is enabled, security will be decreased!")
|
||||||
|
|
||||||
|
// Create Root user
|
||||||
|
assertRootUserNotNull(securityProperties)
|
||||||
|
createSystemUser(
|
||||||
|
securityProperties.root!!,
|
||||||
|
userService,
|
||||||
|
passwordEncoder(),
|
||||||
|
"Root",
|
||||||
|
"User",
|
||||||
|
listOf(Permission.ADMIN)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configure(http: HttpSecurity) {
|
||||||
|
http
|
||||||
|
.headers().frameOptions().disable()
|
||||||
|
.and()
|
||||||
|
.csrf().disable()
|
||||||
|
.addFilter(
|
||||||
|
JwtAuthenticationFilter(authenticationManager(), securityProperties) {
|
||||||
|
userService.updateLastLoginTime(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.addFilter(
|
||||||
|
JwtAuthorizationFilter(securityProperties, authenticationManager()) {
|
||||||
|
userDetailsService.loadUserById(it, false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
|
|
||||||
|
if (!debugMode) {
|
||||||
|
http.authorizeRequests()
|
||||||
|
.antMatchers("/api/login").permitAll()
|
||||||
|
.antMatchers("/api/logout").fullyAuthenticated()
|
||||||
|
.antMatchers("/api/user/current").fullyAuthenticated()
|
||||||
|
.anyRequest().fullyAuthenticated()
|
||||||
|
} else {
|
||||||
|
http
|
||||||
|
.cors()
|
||||||
|
.and()
|
||||||
|
.authorizeRequests()
|
||||||
|
.antMatchers("**").permitAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@Profile("emergency")
|
||||||
|
@EnableConfigurationProperties(CreSecurityProperties::class)
|
||||||
|
class EmergencySecurityConfig(
|
||||||
|
private val securityProperties: CreSecurityProperties,
|
||||||
|
private val environment: Environment
|
||||||
|
) : WebSecurityConfigurerAdapter() {
|
||||||
|
private val rootUserRole = Permission.ADMIN.name
|
||||||
|
|
||||||
|
init {
|
||||||
|
emergencyMode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun corsConfigurationSource() =
|
||||||
|
getCorsConfigurationSource()
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun passwordEncoder() =
|
||||||
|
getPasswordEncoder()
|
||||||
|
|
||||||
|
override fun configure(auth: AuthenticationManagerBuilder) {
|
||||||
|
assertRootUserNotNull(securityProperties)
|
||||||
|
// Create in-memory root user
|
||||||
|
auth.inMemoryAuthentication()
|
||||||
|
.withUser(securityProperties.root!!.id.toString())
|
||||||
|
.password(passwordEncoder().encode(securityProperties.root!!.password))
|
||||||
|
.authorities(SimpleGrantedAuthority(rootUserRole))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configure(http: HttpSecurity) {
|
||||||
|
val debugMode = "debug" in environment.activeProfiles
|
||||||
|
|
||||||
|
http
|
||||||
|
.headers().frameOptions().disable()
|
||||||
|
.and()
|
||||||
|
.csrf().disable()
|
||||||
|
.addFilter(
|
||||||
|
JwtAuthenticationFilter(authenticationManager(), securityProperties) { }
|
||||||
|
)
|
||||||
|
.addFilter(
|
||||||
|
JwtAuthorizationFilter(securityProperties, authenticationManager(), this::loadUserById)
|
||||||
|
)
|
||||||
|
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
|
.and()
|
||||||
|
.authorizeRequests()
|
||||||
|
.antMatchers("**").fullyAuthenticated()
|
||||||
|
.antMatchers("/api/login").permitAll()
|
||||||
|
|
||||||
|
if (debugMode) {
|
||||||
|
http.cors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadUserById(id: Long): UserDetails {
|
||||||
|
assertRootUserNotNull(securityProperties)
|
||||||
|
if (id == securityProperties.root!!.id) {
|
||||||
|
return SpringUser(
|
||||||
|
id.toString(),
|
||||||
|
securityProperties.root!!.password,
|
||||||
|
listOf(SimpleGrantedAuthority(rootUserRole))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw UsernameNotFoundException(id.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class RestAuthenticationEntryPoint : AuthenticationEntryPoint {
|
||||||
|
override fun commence(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
authException: AuthenticationException
|
||||||
|
) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSystemUser(
|
||||||
|
credentials: CreSecurityProperties.SystemUserCredentials,
|
||||||
|
userService: UserService,
|
||||||
|
passwordEncoder: PasswordEncoder,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
permissions: List<Permission>
|
||||||
|
) {
|
||||||
|
Assert.notNull(credentials.id, "A system user has no identifier defined")
|
||||||
|
Assert.notNull(credentials.password, "A system user has no password defined")
|
||||||
|
|
||||||
|
if (!userService.existsById(credentials.id)) {
|
||||||
|
userService.save(
|
||||||
|
User(
|
||||||
|
id = credentials.id,
|
||||||
|
firstName = firstName,
|
||||||
|
lastName = lastName,
|
||||||
|
password = passwordEncoder.encode(credentials.password),
|
||||||
|
isSystemUser = true,
|
||||||
|
permissions = permissions.toMutableSet()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPasswordEncoder() =
|
||||||
|
BCryptPasswordEncoder()
|
||||||
|
|
||||||
|
fun getCorsConfigurationSource() =
|
||||||
|
UrlBasedCorsConfigurationSource().apply {
|
||||||
|
registerCorsConfiguration("/**", CorsConfiguration().apply {
|
||||||
|
allowedOrigins = listOf("http://localhost:4200") // Angular development server
|
||||||
|
allowedMethods = listOf(
|
||||||
|
HttpMethod.GET.name,
|
||||||
|
HttpMethod.POST.name,
|
||||||
|
HttpMethod.PUT.name,
|
||||||
|
HttpMethod.DELETE.name,
|
||||||
|
HttpMethod.OPTIONS.name,
|
||||||
|
HttpMethod.HEAD.name
|
||||||
|
)
|
||||||
|
allowCredentials = true
|
||||||
|
}.applyPermitDefaultValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertRootUserNotNull(securityProperties: CreSecurityProperties) {
|
||||||
|
Assert.notNull(securityProperties.root, "cre.security.root should be defined")
|
||||||
|
}
|
|
@ -0,0 +1,194 @@
|
||||||
|
package dev.fyloz.colorrecipesexplorer.service.config
|
||||||
|
|
||||||
|
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||||
|
import dev.fyloz.colorrecipesexplorer.model.*
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.FileService
|
||||||
|
import dev.fyloz.colorrecipesexplorer.utils.decrypt
|
||||||
|
import dev.fyloz.colorrecipesexplorer.utils.encrypt
|
||||||
|
import org.slf4j.Logger
|
||||||
|
import org.springframework.context.annotation.Lazy
|
||||||
|
import org.springframework.security.crypto.keygen.KeyGenerators
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
interface ConfigurationService {
|
||||||
|
/** Gets all set configurations. */
|
||||||
|
fun getAll(): List<Configuration>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all configurations with keys contained in the given [formattedKeyList].
|
||||||
|
* The [formattedKeyList] contains wanted configuration keys separated by a semi-colon.
|
||||||
|
*/
|
||||||
|
fun getAll(formattedKeyList: String): List<Configuration>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the configuration with the given [key].
|
||||||
|
* If the [key] does not exists, an [InvalidConfigurationKeyException] will be thrown.
|
||||||
|
*/
|
||||||
|
fun get(key: String): Configuration
|
||||||
|
|
||||||
|
/** Gets the configuration with the given [type]. */
|
||||||
|
fun get(type: ConfigurationType): Configuration
|
||||||
|
|
||||||
|
/** Sets the content of each configuration in the given [configurations] list. */
|
||||||
|
fun set(configurations: List<ConfigurationDto>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the content of the configuration matching the given [configuration].
|
||||||
|
* If the given key does not exists, an [InvalidConfigurationKeyException] will be thrown.
|
||||||
|
*/
|
||||||
|
fun set(configuration: ConfigurationDto)
|
||||||
|
|
||||||
|
/** Sets the content given [configuration]. */
|
||||||
|
fun set(configuration: Configuration)
|
||||||
|
|
||||||
|
/** Sets the content of the configuration matching the given [configuration] with a given image. */
|
||||||
|
fun set(configuration: ConfigurationImageDto)
|
||||||
|
|
||||||
|
/** Initialize the properties matching the given [predicate]. */
|
||||||
|
fun initializeProperties(predicate: (ConfigurationType) -> Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const val CONFIGURATION_LOGO_FILE_PATH = "images/logo"
|
||||||
|
const val CONFIGURATION_ICON_FILE_PATH = "images/icon"
|
||||||
|
const val CONFIGURATION_FORMATTED_LIST_DELIMITER = ';'
|
||||||
|
|
||||||
|
@Service("configurationService")
|
||||||
|
class ConfigurationServiceImpl(
|
||||||
|
@Lazy private val fileService: FileService,
|
||||||
|
private val configurationSource: ConfigurationSource,
|
||||||
|
private val securityProperties: CreSecurityProperties,
|
||||||
|
private val logger: Logger
|
||||||
|
) : ConfigurationService {
|
||||||
|
private val saltConfigurationType = ConfigurationType.GENERATED_ENCRYPTION_SALT
|
||||||
|
private val encryptionSalt by lazy {
|
||||||
|
securityProperties.configSalt ?: getGeneratedSalt()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAll() =
|
||||||
|
ConfigurationType.values().mapNotNull {
|
||||||
|
try {
|
||||||
|
get(it)
|
||||||
|
} catch (_: ConfigurationNotSetException) {
|
||||||
|
null
|
||||||
|
} catch (_: InvalidConfigurationKeyException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAll(formattedKeyList: String) =
|
||||||
|
formattedKeyList.split(CONFIGURATION_FORMATTED_LIST_DELIMITER).mapNotNull {
|
||||||
|
try {
|
||||||
|
get(it)
|
||||||
|
} catch (_: ConfigurationNotSetException) {
|
||||||
|
null
|
||||||
|
} catch (_: InvalidConfigurationKeyException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(key: String) =
|
||||||
|
get(key.toConfigurationType())
|
||||||
|
|
||||||
|
override fun get(type: ConfigurationType): Configuration {
|
||||||
|
// Encryption salt should never be returned, but cannot be set as "secure" without encrypting it
|
||||||
|
if (type == ConfigurationType.GENERATED_ENCRYPTION_SALT) throw InvalidConfigurationKeyException(type.key)
|
||||||
|
|
||||||
|
val configuration = configurationSource.get(type) ?: throw ConfigurationNotSetException(type)
|
||||||
|
return if (type.secure) {
|
||||||
|
decryptConfiguration(configuration)
|
||||||
|
} else {
|
||||||
|
configuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun set(configurations: List<ConfigurationDto>) {
|
||||||
|
configurationSource.set(
|
||||||
|
configurations
|
||||||
|
.map(::configuration)
|
||||||
|
.map(this::encryptConfigurationIfSecure)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun set(configuration: ConfigurationDto) =
|
||||||
|
set(configuration(configuration))
|
||||||
|
|
||||||
|
override fun set(configuration: Configuration) {
|
||||||
|
configurationSource.set(encryptConfigurationIfSecure(configuration))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun set(configuration: ConfigurationImageDto) {
|
||||||
|
val filePath = when (val configurationType = configuration.key.toConfigurationType()) {
|
||||||
|
ConfigurationType.INSTANCE_LOGO_PATH -> CONFIGURATION_LOGO_FILE_PATH
|
||||||
|
ConfigurationType.INSTANCE_ICON_PATH -> CONFIGURATION_ICON_FILE_PATH
|
||||||
|
else -> throw InvalidImageConfigurationException(configurationType)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileService.write(configuration.image, filePath, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initializeProperties(predicate: (ConfigurationType) -> Boolean) {
|
||||||
|
ConfigurationType.values()
|
||||||
|
.filter(predicate)
|
||||||
|
.filter { !it.computed } // Can't initialize computed configurations
|
||||||
|
.filter { it != ConfigurationType.GENERATED_ENCRYPTION_SALT }
|
||||||
|
.forEach {
|
||||||
|
try {
|
||||||
|
get(it)
|
||||||
|
} catch (_: ConfigurationNotSetException) {
|
||||||
|
with(it.defaultContent) {
|
||||||
|
if (this != null) { // Ignores configurations with null default values
|
||||||
|
logger.info("Configuration ${it.key} was not set and will be initialized to a default value")
|
||||||
|
set(configuration(type = it, content = this.toString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encryptConfigurationIfSecure(configuration: Configuration) =
|
||||||
|
with(configuration) {
|
||||||
|
if (type.secure) {
|
||||||
|
encryptConfiguration(this)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encryptConfiguration(configuration: Configuration) =
|
||||||
|
with(configuration) {
|
||||||
|
configuration(
|
||||||
|
type = type,
|
||||||
|
content = content.encrypt(type.key, encryptionSalt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptConfiguration(configuration: Configuration) =
|
||||||
|
with(configuration) {
|
||||||
|
try {
|
||||||
|
configuration(
|
||||||
|
type = type,
|
||||||
|
content = content.decrypt(type.key, encryptionSalt)
|
||||||
|
)
|
||||||
|
} catch (ex: IllegalStateException) {
|
||||||
|
logger.error(
|
||||||
|
"Could not read encrypted configuration, using default value. Are you using the correct salt?",
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
configuration(type = type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGeneratedSalt(): String {
|
||||||
|
logger.warn("Sensitives configurations encryption salt was not configured, using generated salt")
|
||||||
|
logger.warn("Consider configuring the encryption salt. More details at: https://git.fyloz.dev/color-recipes-explorer/backend/-/wikis/Configuration/S%C3%A9curit%C3%A9/#sel")
|
||||||
|
|
||||||
|
var saltConfiguration = configurationSource.get(saltConfigurationType)
|
||||||
|
if (saltConfiguration == null) {
|
||||||
|
val generatedSalt = KeyGenerators.string().generateKey()
|
||||||
|
saltConfiguration = configuration(type = saltConfigurationType, content = generatedSalt)
|
||||||
|
configurationSource.set(saltConfiguration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return saltConfiguration.content
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,171 @@
|
||||||
|
package dev.fyloz.colorrecipesexplorer.service.config
|
||||||
|
|
||||||
|
import dev.fyloz.colorrecipesexplorer.SUPPORTED_DATABASE_VERSION
|
||||||
|
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
|
||||||
|
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
||||||
|
import dev.fyloz.colorrecipesexplorer.model.CannotSetComputedConfigurationException
|
||||||
|
import dev.fyloz.colorrecipesexplorer.model.Configuration
|
||||||
|
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
||||||
|
import dev.fyloz.colorrecipesexplorer.model.configuration
|
||||||
|
import dev.fyloz.colorrecipesexplorer.repository.ConfigurationRepository
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.create
|
||||||
|
import dev.fyloz.colorrecipesexplorer.utils.excludeAll
|
||||||
|
import org.slf4j.Logger
|
||||||
|
import org.springframework.boot.info.BuildProperties
|
||||||
|
import org.springframework.context.annotation.Lazy
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
const val CONFIGURATION_FILE_PATH = "config.properties"
|
||||||
|
const val CONFIGURATION_FILE_COMMENT = "---Color Recipes Explorer configuration---"
|
||||||
|
|
||||||
|
interface ConfigurationSource {
|
||||||
|
fun get(type: ConfigurationType): Configuration?
|
||||||
|
fun set(configuration: Configuration)
|
||||||
|
fun set(configurations: Iterable<Configuration>)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component("configurationSource")
|
||||||
|
class CompositeConfigurationSource(
|
||||||
|
@Lazy private val configurationRepository: ConfigurationRepository,
|
||||||
|
private val properties: CreProperties,
|
||||||
|
private val buildInfo: BuildProperties,
|
||||||
|
private val logger: Logger
|
||||||
|
) : ConfigurationSource {
|
||||||
|
private val repository by lazy { RepositoryConfigurationSource(configurationRepository) }
|
||||||
|
private val file by lazy {
|
||||||
|
FileConfigurationSource("${properties.configDirectory}/$CONFIGURATION_FILE_PATH")
|
||||||
|
}
|
||||||
|
private val computed by lazy {
|
||||||
|
ComputedConfigurationSource(buildInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(type: ConfigurationType) =
|
||||||
|
when {
|
||||||
|
type.file -> file.get(type)
|
||||||
|
type.computed -> computed.get(type)
|
||||||
|
!emergencyMode -> repository.get(type)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun set(configuration: Configuration) =
|
||||||
|
when {
|
||||||
|
configuration.type.file -> file.set(configuration)
|
||||||
|
configuration.type.computed -> throw CannotSetComputedConfigurationException(configuration.type)
|
||||||
|
!emergencyMode -> repository.set(configuration)
|
||||||
|
else -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun set(configurations: Iterable<Configuration>) {
|
||||||
|
val mutableConfigurations = configurations.toMutableList()
|
||||||
|
val fileConfigurations = mutableConfigurations.excludeAll { it.type.file }
|
||||||
|
val repositoryConfigurations = mutableConfigurations.excludeAll { !emergencyMode }
|
||||||
|
|
||||||
|
repository.set(repositoryConfigurations)
|
||||||
|
file.set(fileConfigurations)
|
||||||
|
|
||||||
|
mutableConfigurations.forEach {
|
||||||
|
logger.warn("Could not find where to store updated value of configuration '${it.key}'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RepositoryConfigurationSource(
|
||||||
|
private val repository: ConfigurationRepository
|
||||||
|
) : ConfigurationSource {
|
||||||
|
override fun get(type: ConfigurationType) =
|
||||||
|
repository.findByIdOrNull(type.key)?.toConfiguration()
|
||||||
|
|
||||||
|
override fun set(configuration: Configuration) {
|
||||||
|
repository.save(configuration.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun set(configurations: Iterable<Configuration>) =
|
||||||
|
configurations.forEach { set(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FileConfigurationSource(
|
||||||
|
private val configFilePath: String
|
||||||
|
) : ConfigurationSource {
|
||||||
|
private val properties = Properties().apply {
|
||||||
|
with(File(configFilePath)) {
|
||||||
|
if (!this.exists()) this.create()
|
||||||
|
FileInputStream(this).use {
|
||||||
|
this@apply.load(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(type: ConfigurationType) =
|
||||||
|
if (properties.containsKey(type.key))
|
||||||
|
configuration(
|
||||||
|
type,
|
||||||
|
getConfigurationContent(type.key),
|
||||||
|
LocalDateTime.parse(getConfigurationContent(configurationLastUpdateKey(type.key)))
|
||||||
|
)
|
||||||
|
else null
|
||||||
|
|
||||||
|
override fun set(configuration: Configuration) {
|
||||||
|
setConfigurationContent(configuration.type.key, configuration.content)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun set(configurations: Iterable<Configuration>) {
|
||||||
|
configurations.forEach {
|
||||||
|
setConfigurationContent(it.type.key, it.content)
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
FileOutputStream(configFilePath).use {
|
||||||
|
properties.store(it, CONFIGURATION_FILE_COMMENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getConfigurationContent(key: String) =
|
||||||
|
properties[key] as String
|
||||||
|
|
||||||
|
private fun setConfigurationContent(key: String, content: String) {
|
||||||
|
properties[key] = content
|
||||||
|
properties[configurationLastUpdateKey(key)] = LocalDateTime.now().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configurationLastUpdateKey(key: String) = "$key.last-updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ComputedConfigurationSource(
|
||||||
|
private val buildInfo: BuildProperties
|
||||||
|
) : ConfigurationSource {
|
||||||
|
override fun get(type: ConfigurationType) = configuration(
|
||||||
|
type, when (type) {
|
||||||
|
ConfigurationType.EMERGENCY_MODE_ENABLED -> emergencyMode
|
||||||
|
ConfigurationType.BUILD_VERSION -> buildInfo.version
|
||||||
|
ConfigurationType.BUILD_TIME -> LocalDate.ofInstant(buildInfo.time, ZoneId.systemDefault()).toString()
|
||||||
|
ConfigurationType.DATABASE_SUPPORTED_VERSION -> SUPPORTED_DATABASE_VERSION
|
||||||
|
ConfigurationType.JAVA_VERSION -> Runtime.version()
|
||||||
|
ConfigurationType.OPERATING_SYSTEM -> "${System.getProperty("os.name")} ${System.getProperty("os.version")} ${
|
||||||
|
System.getProperty(
|
||||||
|
"os.arch"
|
||||||
|
)
|
||||||
|
}"
|
||||||
|
else -> throw IllegalArgumentException("Cannot get the value of the configuration with the key ${type.key} because it is not a computed configuration")
|
||||||
|
}.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun set(configuration: Configuration) {
|
||||||
|
throw UnsupportedOperationException("Cannot set computed configurations")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun set(configurations: Iterable<Configuration>) {
|
||||||
|
throw UnsupportedOperationException("Cannot set computed configurations")
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue