Merge pull request 'Migration to Drone CI' (#8) from features into master
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
Reviewed-on: #8
This commit is contained in:
commit
65eceff3a7
|
@ -0,0 +1,75 @@
|
||||||
|
kind: pipeline
|
||||||
|
name: default
|
||||||
|
type: docker
|
||||||
|
|
||||||
|
environment:
|
||||||
|
CRE_VERSION: ${DRONE_BUILD_NUMBER}
|
||||||
|
CRE_ARTIFACT_NAME: ColorRecipesExplorer
|
||||||
|
CRE_REGISTRY_IMAGE: registry.fyloz.dev:5443/colorrecipesexplorer/backend
|
||||||
|
CRE_PORT: 9090
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: test
|
||||||
|
image: gradle:7.1-jdk11
|
||||||
|
commands:
|
||||||
|
- gradle test
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
image: gradle:7.1-jdk11
|
||||||
|
commands:
|
||||||
|
- gradle bootJar -Pversion=$CRE_VERSION
|
||||||
|
- mv build/libs/ColorRecipesExplorer-$CRE_VERSION.jar $CRE_ARTIFACT_NAME.jar
|
||||||
|
- echo -n "latest,$CRE_VERSION" > .tags
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
events: [ push, tag ]
|
||||||
|
|
||||||
|
- name: containerize
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
build_args:
|
||||||
|
- JAVA_VERSION=11
|
||||||
|
build_args_from_env:
|
||||||
|
- CRE_ARTIFACT_NAME
|
||||||
|
- CRE_PORT
|
||||||
|
repo: registry.fyloz.dev:5443/colorrecipesexplorer/backend
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
events: [ push, tag ]
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: alpine:latest
|
||||||
|
environment:
|
||||||
|
DEPLOY_SERVER:
|
||||||
|
from_secret: deploy_server
|
||||||
|
DEPLOY_SERVER_USERNAME:
|
||||||
|
from_secret: deploy_server_username
|
||||||
|
DEPLOY_SERVER_SSH_PORT:
|
||||||
|
from_secret: deploy_server_ssh_port
|
||||||
|
DEPLOY_SERVER_SSH_KEY:
|
||||||
|
from_secret: deploy_server_ssh_key
|
||||||
|
DEPLOY_CONTAINER_NAME: cre_backend-${DRONE_BRANCH}
|
||||||
|
DEPLOY_SPRING_PROFILES: mysql,rest
|
||||||
|
DEPLOY_DATA_VOLUME: /var/cre/data
|
||||||
|
DEPLOY_CONFIG_VOLUME: /var/cre/config
|
||||||
|
commands:
|
||||||
|
- apk update
|
||||||
|
- apk add --no-cache openssh-client
|
||||||
|
- mkdir -p ~/.ssh
|
||||||
|
- echo "$DEPLOY_SERVER_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
|
||||||
|
- chmod 700 ~/.ssh/id_rsa
|
||||||
|
- eval $(ssh-agent -s)
|
||||||
|
- ssh-add ~/.ssh/id_rsa
|
||||||
|
- ssh-keyscan -p $DEPLOY_SERVER_SSH_PORT -H $DEPLOY_SERVER >> ~/.ssh/known_hosts
|
||||||
|
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
|
||||||
|
- ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker stop $DEPLOY_CONTAINER_NAME || true && docker rm $DEPLOY_CONTAINER_NAME || true"
|
||||||
|
- ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker pull $CRE_REGISTRY_IMAGE:latest"
|
||||||
|
- ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker run -d -p $CRE_PORT:$CRE_PORT --name=$DEPLOY_CONTAINER_NAME -v $DEPLOY_DATA_VOLUME:/usr/bin/cre/data -v $DEPLOY_CONFIG_VOLUME:/usr/bin/cre/config -e spring_profiles_active=$SPRING_PROFILES $CRE_REGISTRY_IMAGE"
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
events: [ push, tag ]
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
gradle/
|
gradle/
|
||||||
build/
|
build/
|
||||||
logs/
|
logs/
|
||||||
config/
|
|
||||||
data/
|
data/
|
||||||
dokka/
|
dokka/
|
||||||
dist/
|
dist/
|
||||||
|
|
|
@ -4,11 +4,11 @@ FROM openjdk:$JAVA_VERSION
|
||||||
|
|
||||||
WORKDIR /usr/bin/cre/
|
WORKDIR /usr/bin/cre/
|
||||||
|
|
||||||
ARG ARTIFACT_NAME=ColorRecipesExplorer
|
ARG CRE_ARTIFACT_NAME=ColorRecipesExplorer
|
||||||
COPY $ARTIFACT_NAME.jar ColorRecipesExplorer.jar
|
COPY $CRE_ARTIFACT_NAME.jar ColorRecipesExplorer.jar
|
||||||
|
|
||||||
ARG PORT=9090
|
ARG CRE_PORT=9090
|
||||||
EXPOSE $PORT
|
EXPOSE $CRE_PORT
|
||||||
|
|
||||||
ENV spring_profiles_active=h2,rest
|
ENV spring_profiles_active=h2,rest
|
||||||
ENV server_port=$PORT
|
ENV server_port=$PORT
|
||||||
|
|
|
@ -2,12 +2,12 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
group = "dev.fyloz.colorrecipesexplorer"
|
group = "dev.fyloz.colorrecipesexplorer"
|
||||||
|
|
||||||
val kotlinVersion = "1.5.0"
|
val kotlinVersion = "1.5.21"
|
||||||
val springBootVersion = "2.3.4.RELEASE"
|
val springBootVersion = "2.3.4.RELEASE"
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
// Outer scope variables can't be accessed in the plugins section, so we have to redefine them here
|
// Outer scope variables can't be accessed in the plugins section, so we have to redefine them here
|
||||||
val kotlinVersion = "1.5.0"
|
val kotlinVersion = "1.5.21"
|
||||||
val springBootVersion = "2.3.4.RELEASE"
|
val springBootVersion = "2.3.4.RELEASE"
|
||||||
|
|
||||||
id("java")
|
id("java")
|
||||||
|
@ -46,7 +46,7 @@ dependencies {
|
||||||
implementation("org.springframework.boot:spring-boot-devtools:${springBootVersion}")
|
implementation("org.springframework.boot:spring-boot-devtools:${springBootVersion}")
|
||||||
|
|
||||||
testImplementation("org.springframework:spring-test:5.1.6.RELEASE")
|
testImplementation("org.springframework:spring-test:5.1.6.RELEASE")
|
||||||
testImplementation("org.mockito:mockito-inline:3.6.0")
|
testImplementation("org.mockito:mockito-inline:3.11.2")
|
||||||
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
|
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.3.2")
|
testImplementation("org.junit.jupiter:junit-jupiter-api:5.3.2")
|
||||||
testImplementation("io.mockk:mockk:1.10.6")
|
testImplementation("io.mockk:mockk:1.10.6")
|
||||||
|
@ -83,8 +83,8 @@ sourceSets {
|
||||||
|
|
||||||
tasks.test {
|
tasks.test {
|
||||||
reports {
|
reports {
|
||||||
junitXml.isEnabled = true
|
junitXml.required.set(true)
|
||||||
html.isEnabled = false
|
html.required.set(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
@ -99,7 +99,6 @@ tasks.withType<JavaCompile>() {
|
||||||
tasks.withType<KotlinCompile>().all {
|
tasks.withType<KotlinCompile>().all {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
useIR = true
|
|
||||||
freeCompilerArgs = listOf(
|
freeCompilerArgs = listOf(
|
||||||
"-Xopt-in=kotlin.contracts.ExperimentalContracts",
|
"-Xopt-in=kotlin.contracts.ExperimentalContracts",
|
||||||
"-Xinline-classes"
|
"-Xinline-classes"
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
version: "3.1"
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
image: fyloz.dev:5443/color-recipes-explorer/frontend:latest
|
||||||
|
ports:
|
||||||
|
- 4200:80
|
||||||
|
database:
|
||||||
|
image: mysql
|
||||||
|
command: --default-authentication-plugin=mysql_native_password
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: "pass"
|
||||||
|
MYSQL_DATABASE: "cre"
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
ARG JDK_VERSION=11
|
ARG JDK_VERSION=11
|
||||||
ARG GRADLE_VERSION=6.8
|
ARG GRADLE_VERSION=7.1
|
||||||
|
|
||||||
FROM gradle:$GRADLE_VERSION-jdk$JDK_VERSION
|
FROM gradle:$GRADLE_VERSION-jdk$JDK_VERSION
|
||||||
WORKDIR /usr/src/cre/
|
WORKDIR /usr/src/cre/
|
||||||
|
|
|
@ -1,20 +1,17 @@
|
||||||
package dev.fyloz.colorrecipesexplorer
|
package dev.fyloz.colorrecipesexplorer
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.config.FileConfiguration
|
|
||||||
import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabase
|
import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabase
|
||||||
import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabaseException
|
|
||||||
import dev.fyloz.colorrecipesexplorer.databasemanager.databaseContext
|
import dev.fyloz.colorrecipesexplorer.databasemanager.databaseContext
|
||||||
import dev.fyloz.colorrecipesexplorer.databasemanager.databaseUpdaterProperties
|
import dev.fyloz.colorrecipesexplorer.databasemanager.databaseUpdaterProperties
|
||||||
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
import org.slf4j.Logger
|
import org.slf4j.Logger
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
|
||||||
import org.springframework.boot.jdbc.DataSourceBuilder
|
import org.springframework.boot.jdbc.DataSourceBuilder
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.context.annotation.DependsOn
|
import org.springframework.context.annotation.DependsOn
|
||||||
import org.springframework.context.annotation.Profile
|
import org.springframework.context.annotation.Profile
|
||||||
import org.springframework.core.env.ConfigurableEnvironment
|
import org.springframework.core.env.ConfigurableEnvironment
|
||||||
import java.lang.RuntimeException
|
|
||||||
import javax.sql.DataSource
|
import javax.sql.DataSource
|
||||||
|
|
||||||
const val SUPPORTED_DATABASE_VERSION = 5
|
const val SUPPORTED_DATABASE_VERSION = 5
|
||||||
|
@ -23,21 +20,20 @@ val DATABASE_NAME_REGEX = Regex("(\\w+)$")
|
||||||
|
|
||||||
@Profile("!emergency")
|
@Profile("!emergency")
|
||||||
@Configuration
|
@Configuration
|
||||||
@DependsOn("configurationsInitializer")
|
@DependsOn("configurationsInitializer", "configurationService")
|
||||||
class DataSourceConfiguration {
|
class DataSourceConfiguration {
|
||||||
@Bean(name = ["dataSource"])
|
@Bean(name = ["dataSource"])
|
||||||
fun customDataSource(
|
fun customDataSource(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
environment: ConfigurableEnvironment,
|
environment: ConfigurableEnvironment,
|
||||||
fileConfiguration: FileConfiguration,
|
configurationService: ConfigurationService
|
||||||
databaseUpdaterProperties: DatabaseUpdaterProperties
|
|
||||||
): DataSource {
|
): DataSource {
|
||||||
fun getConfiguration(type: ConfigurationType, defaultProperty: String) =
|
fun getConfiguration(type: ConfigurationType) =
|
||||||
fileConfiguration.get(type)?.content ?: defaultProperty
|
configurationService.get(type).content
|
||||||
|
|
||||||
val databaseUrl = "jdbc:" + getConfiguration(ConfigurationType.DATABASE_URL, databaseUpdaterProperties.url)
|
val databaseUrl = "jdbc:" + getConfiguration(ConfigurationType.DATABASE_URL)
|
||||||
val databaseUsername = getConfiguration(ConfigurationType.DATABASE_USER, databaseUpdaterProperties.username)
|
val databaseUsername = getConfiguration(ConfigurationType.DATABASE_USER)
|
||||||
val databasePassword = getConfiguration(ConfigurationType.DATABASE_PASSWORD, databaseUpdaterProperties.password)
|
val databasePassword = getConfiguration(ConfigurationType.DATABASE_PASSWORD)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runDatabaseVersionCheck(logger, databaseUrl, DatabaseUpdaterProperties().apply {
|
runDatabaseVersionCheck(logger, databaseUrl, DatabaseUpdaterProperties().apply {
|
||||||
|
@ -158,7 +154,6 @@ fun throwUnsupportedDatabaseVersion(version: Int, logger: Logger) {
|
||||||
throw DatabaseVersioningException.UnsupportedDatabaseVersion(version)
|
throw DatabaseVersioningException.UnsupportedDatabaseVersion(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ConfigurationProperties(prefix = "cre.database")
|
|
||||||
class DatabaseUpdaterProperties {
|
class DatabaseUpdaterProperties {
|
||||||
var url: String = ""
|
var url: String = ""
|
||||||
var username: String = ""
|
var username: String = ""
|
||||||
|
|
|
@ -6,15 +6,16 @@ import dev.fyloz.colorrecipesexplorer.emergencyMode
|
||||||
import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES
|
import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES
|
||||||
import dev.fyloz.colorrecipesexplorer.restartApplication
|
import dev.fyloz.colorrecipesexplorer.restartApplication
|
||||||
import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService
|
import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
import org.slf4j.Logger
|
import org.slf4j.Logger
|
||||||
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent
|
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|
||||||
import org.springframework.context.ApplicationListener
|
import org.springframework.context.ApplicationListener
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.context.annotation.Profile
|
import org.springframework.context.annotation.Profile
|
||||||
import org.springframework.core.Ordered
|
import org.springframework.core.Ordered
|
||||||
import org.springframework.core.annotation.Order
|
import org.springframework.core.annotation.Order
|
||||||
|
import javax.annotation.PostConstruct
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@ -22,6 +23,7 @@ import kotlin.concurrent.thread
|
||||||
@Profile("!emergency")
|
@Profile("!emergency")
|
||||||
class ApplicationReadyListener(
|
class ApplicationReadyListener(
|
||||||
private val materialTypeService: MaterialTypeService,
|
private val materialTypeService: MaterialTypeService,
|
||||||
|
private val configurationService: ConfigurationService,
|
||||||
private val materialTypeProperties: MaterialTypeProperties,
|
private val materialTypeProperties: MaterialTypeProperties,
|
||||||
private val creProperties: CreProperties,
|
private val creProperties: CreProperties,
|
||||||
private val logger: Logger
|
private val logger: Logger
|
||||||
|
@ -37,9 +39,29 @@ class ApplicationReadyListener(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes)
|
initDatabaseConfigurations()
|
||||||
|
initMaterialTypes()
|
||||||
CRE_PROPERTIES = creProperties
|
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> {
|
class ApplicationInitializer : ApplicationListener<ApplicationEnvironmentPreparedEvent> {
|
|
@ -1,58 +0,0 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.config
|
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.configuration
|
|
||||||
import dev.fyloz.colorrecipesexplorer.service.create
|
|
||||||
import org.springframework.context.annotation.Bean
|
|
||||||
import org.springframework.context.annotation.Configuration
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
const val CONFIGURATION_FILE_PATH = "config.properties"
|
|
||||||
const val CONFIGURATION_FILE_COMMENT = "---Color Recipes Explorer configuration---"
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
class ConfigurationsInitializer(
|
|
||||||
private val creProperties: CreProperties
|
|
||||||
) {
|
|
||||||
@Bean
|
|
||||||
fun fileConfiguration() = FileConfiguration("${creProperties.configDirectory}/$CONFIGURATION_FILE_PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
class FileConfiguration(private val configFilePath: String) {
|
|
||||||
val properties = Properties().apply {
|
|
||||||
with(File(configFilePath)) {
|
|
||||||
if (!this.exists()) this.create()
|
|
||||||
FileInputStream(this).use {
|
|
||||||
this@apply.load(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun get(type: ConfigurationType) =
|
|
||||||
if (properties.containsKey(type.key))
|
|
||||||
configuration(
|
|
||||||
type,
|
|
||||||
properties[type.key] as String,
|
|
||||||
LocalDateTime.parse(properties[configurationLastUpdateKey(type.key)] as String)
|
|
||||||
)
|
|
||||||
else null
|
|
||||||
|
|
||||||
fun set(type: ConfigurationType, content: String) {
|
|
||||||
properties[type.key] = content
|
|
||||||
properties[configurationLastUpdateKey(type.key)] = LocalDateTime.now().toString()
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun save() {
|
|
||||||
FileOutputStream(configFilePath).use {
|
|
||||||
properties.store(it, CONFIGURATION_FILE_COMMENT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configurationLastUpdateKey(key: String) = "$key.last-updated"
|
|
||||||
}
|
|
|
@ -1,102 +0,0 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.config
|
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|
||||||
import org.springframework.context.annotation.Bean
|
|
||||||
import org.springframework.context.annotation.Configuration
|
|
||||||
import org.springframework.context.annotation.Profile
|
|
||||||
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.web.builders.HttpSecurity
|
|
||||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
|
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
|
||||||
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.web.cors.CorsConfiguration
|
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
|
||||||
import org.springframework.security.core.userdetails.User as SpringUser
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@Profile("emergency")
|
|
||||||
@EnableConfigurationProperties(SecurityConfigurationProperties::class)
|
|
||||||
class EmergencySecurityConfig(
|
|
||||||
val securityConfigurationProperties: SecurityConfigurationProperties,
|
|
||||||
val environment: Environment
|
|
||||||
) : WebSecurityConfigurerAdapter() {
|
|
||||||
init {
|
|
||||||
emergencyMode = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun corsConfigurationSource() =
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun passwordEncoder() =
|
|
||||||
BCryptPasswordEncoder()
|
|
||||||
|
|
||||||
override fun configure(auth: AuthenticationManagerBuilder) {
|
|
||||||
auth.inMemoryAuthentication()
|
|
||||||
.withUser(securityConfigurationProperties.root!!.id.toString())
|
|
||||||
.password(passwordEncoder().encode(securityConfigurationProperties.root!!.password))
|
|
||||||
.authorities(SimpleGrantedAuthority("ADMIN"))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun configure(http: HttpSecurity) {
|
|
||||||
val debugMode = "debug" in environment.activeProfiles
|
|
||||||
|
|
||||||
http
|
|
||||||
.headers().frameOptions().disable()
|
|
||||||
.and()
|
|
||||||
.csrf().disable()
|
|
||||||
.addFilter(
|
|
||||||
JwtAuthenticationFilter(
|
|
||||||
authenticationManager(),
|
|
||||||
securityConfigurationProperties
|
|
||||||
) { }
|
|
||||||
)
|
|
||||||
.addFilter(
|
|
||||||
JwtAuthorizationFilter(
|
|
||||||
securityConfigurationProperties,
|
|
||||||
authenticationManager(),
|
|
||||||
this::loadUserById
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
|
||||||
.and()
|
|
||||||
.authorizeRequests()
|
|
||||||
.antMatchers("**").permitAll()
|
|
||||||
|
|
||||||
if (debugMode) {
|
|
||||||
http
|
|
||||||
.cors()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadUserById(id: Long): UserDetails {
|
|
||||||
if (id == securityConfigurationProperties.root!!.id) {
|
|
||||||
return SpringUser(
|
|
||||||
id.toString(),
|
|
||||||
securityConfigurationProperties.root!!.password,
|
|
||||||
listOf(SimpleGrantedAuthority("ADMIN"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw UsernameNotFoundException(id.toString())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -11,7 +11,7 @@ import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(MaterialTypeProperties::class, CreProperties::class, DatabaseUpdaterProperties::class)
|
@EnableConfigurationProperties(MaterialTypeProperties::class, CreProperties::class)
|
||||||
class SpringConfiguration {
|
class SpringConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
fun logger(): Logger = LoggerFactory.getLogger(ColorRecipesExplorerApplication::class.java)
|
fun logger(): Logger = LoggerFactory.getLogger(ColorRecipesExplorerApplication::class.java)
|
||||||
|
|
|
@ -1,293 +0,0 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.config
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.User
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest
|
|
||||||
import dev.fyloz.colorrecipesexplorer.service.CreUserDetailsService
|
|
||||||
import dev.fyloz.colorrecipesexplorer.service.UserService
|
|
||||||
import io.jsonwebtoken.ExpiredJwtException
|
|
||||||
import io.jsonwebtoken.Jwts
|
|
||||||
import io.jsonwebtoken.SignatureAlgorithm
|
|
||||||
import org.slf4j.Logger
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|
||||||
import org.springframework.context.annotation.Bean
|
|
||||||
import org.springframework.context.annotation.Configuration
|
|
||||||
import org.springframework.context.annotation.Lazy
|
|
||||||
import org.springframework.context.annotation.Profile
|
|
||||||
import org.springframework.core.env.Environment
|
|
||||||
import org.springframework.http.HttpMethod
|
|
||||||
import org.springframework.security.authentication.AuthenticationManager
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
|
||||||
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.Authentication
|
|
||||||
import org.springframework.security.core.AuthenticationException
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
|
||||||
import org.springframework.security.web.AuthenticationEntryPoint
|
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
|
||||||
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import org.springframework.util.Assert
|
|
||||||
import org.springframework.web.cors.CorsConfiguration
|
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
|
||||||
import org.springframework.web.util.WebUtils
|
|
||||||
import java.util.*
|
|
||||||
import javax.annotation.PostConstruct
|
|
||||||
import javax.servlet.FilterChain
|
|
||||||
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(SecurityConfigurationProperties::class)
|
|
||||||
class WebSecurityConfig(
|
|
||||||
val securityConfigurationProperties: SecurityConfigurationProperties,
|
|
||||||
@Lazy val userDetailsService: CreUserDetailsService,
|
|
||||||
@Lazy val userService: UserService,
|
|
||||||
val environment: Environment,
|
|
||||||
val logger: Logger
|
|
||||||
) : WebSecurityConfigurerAdapter() {
|
|
||||||
var debugMode = false
|
|
||||||
|
|
||||||
override fun configure(authBuilder: AuthenticationManagerBuilder) {
|
|
||||||
authBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun passwordEncoder() =
|
|
||||||
BCryptPasswordEncoder()
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
override fun authenticationManagerBean(): AuthenticationManager =
|
|
||||||
super.authenticationManagerBean()
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun corsConfigurationSource() =
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
fun initWebSecurity() {
|
|
||||||
fun createUser(
|
|
||||||
credentials: SecurityConfigurationProperties.SystemUserCredentials?,
|
|
||||||
firstName: String,
|
|
||||||
lastName: String,
|
|
||||||
permissions: List<Permission>
|
|
||||||
) {
|
|
||||||
if (emergencyMode) {
|
|
||||||
logger.error("Emergency mode is enabled, root user will not be created")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.notNull(credentials, "No root user has been defined.")
|
|
||||||
credentials!!
|
|
||||||
Assert.notNull(credentials.id, "The root user has no identifier defined.")
|
|
||||||
Assert.notNull(credentials.password, "The root 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()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createUser(securityConfigurationProperties.root, "Root", "User", listOf(Permission.ADMIN))
|
|
||||||
debugMode = "debug" in environment.activeProfiles
|
|
||||||
if (debugMode) logger.warn("Debug mode is enabled, security will be disabled!")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun configure(http: HttpSecurity) {
|
|
||||||
http
|
|
||||||
.headers().frameOptions().disable()
|
|
||||||
.and()
|
|
||||||
.csrf().disable()
|
|
||||||
.addFilter(
|
|
||||||
JwtAuthenticationFilter(
|
|
||||||
authenticationManager(),
|
|
||||||
securityConfigurationProperties
|
|
||||||
) { userService.updateLastLoginTime(it) }
|
|
||||||
)
|
|
||||||
.addFilter(
|
|
||||||
JwtAuthorizationFilter(
|
|
||||||
securityConfigurationProperties,
|
|
||||||
authenticationManager()
|
|
||||||
) { userDetailsService.loadUserById(it, false) }
|
|
||||||
)
|
|
||||||
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
|
||||||
|
|
||||||
if (!debugMode) {
|
|
||||||
http.authorizeRequests()
|
|
||||||
.antMatchers("/api/login").permitAll()
|
|
||||||
.antMatchers("/api/logout").authenticated()
|
|
||||||
.antMatchers("/api/user/current").authenticated()
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
} else {
|
|
||||||
http
|
|
||||||
.cors()
|
|
||||||
.and()
|
|
||||||
.authorizeRequests()
|
|
||||||
.antMatchers("**").permitAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class RestAuthenticationEntryPoint : AuthenticationEntryPoint {
|
|
||||||
override fun commence(
|
|
||||||
request: HttpServletRequest,
|
|
||||||
response: HttpServletResponse,
|
|
||||||
authException: AuthenticationException
|
|
||||||
) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
const val authorizationCookieName = "Authorization"
|
|
||||||
const val defaultGroupCookieName = "Default-Group"
|
|
||||||
val blacklistedJwtTokens = mutableListOf<String>()
|
|
||||||
|
|
||||||
class JwtAuthenticationFilter(
|
|
||||||
private val authManager: AuthenticationManager,
|
|
||||||
private val securityConfigurationProperties: SecurityConfigurationProperties,
|
|
||||||
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 SpringUser).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: SecurityConfigurationProperties,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ConfigurationProperties("cre.security")
|
|
||||||
class SecurityConfigurationProperties {
|
|
||||||
var jwtSecret: String? = null
|
|
||||||
var jwtDuration: Long? = null
|
|
||||||
var root: SystemUserCredentials? = null
|
|
||||||
|
|
||||||
class SystemUserCredentials(var id: Long? = null, var password: String? = null)
|
|
||||||
}
|
|
|
@ -1,15 +1,31 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.config.properties
|
package dev.fyloz.colorrecipesexplorer.config.properties
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||||
|
import kotlin.properties.Delegates.notNull
|
||||||
|
|
||||||
const val DEFAULT_DATA_DIRECTORY = "data"
|
const val DEFAULT_DATA_DIRECTORY = "data"
|
||||||
const val DEFAULT_CONFIG_DIRECTORY = "config"
|
const val DEFAULT_CONFIG_DIRECTORY = "config"
|
||||||
const val DEFAULT_DEPLOYMENT_URL = "http://localhost"
|
|
||||||
|
|
||||||
@ConfigurationProperties(prefix = "cre.server")
|
@ConfigurationProperties(prefix = "cre.server")
|
||||||
class CreProperties {
|
class CreProperties {
|
||||||
var dataDirectory: String = DEFAULT_DATA_DIRECTORY
|
var dataDirectory: String = DEFAULT_DATA_DIRECTORY
|
||||||
var configDirectory: String = DEFAULT_CONFIG_DIRECTORY
|
var configDirectory: String = DEFAULT_CONFIG_DIRECTORY
|
||||||
var deploymentUrl: String = DEFAULT_DEPLOYMENT_URL
|
}
|
||||||
var cacheGeneratedFiles: Boolean = false
|
|
||||||
|
@ConfigurationProperties(prefix = "cre.security")
|
||||||
|
class CreSecurityProperties {
|
||||||
|
// JWT
|
||||||
|
var jwtSecret by notNull<String>()
|
||||||
|
var jwtDuration by notNull<Long>()
|
||||||
|
|
||||||
|
// Configs
|
||||||
|
var configSalt: String? = null
|
||||||
|
|
||||||
|
// Users
|
||||||
|
var root: SystemUserCredentials? = null
|
||||||
|
|
||||||
|
class SystemUserCredentials{
|
||||||
|
var id by notNull<Long>()
|
||||||
|
var password by notNull<String>()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,6 @@ import org.springframework.util.Assert
|
||||||
@ConfigurationProperties(prefix = "entities.material-types")
|
@ConfigurationProperties(prefix = "entities.material-types")
|
||||||
class MaterialTypeProperties {
|
class MaterialTypeProperties {
|
||||||
var systemTypes: MutableList<MaterialTypeProperty> = mutableListOf()
|
var systemTypes: MutableList<MaterialTypeProperty> = mutableListOf()
|
||||||
var baseName: String = ""
|
|
||||||
|
|
||||||
data class MaterialTypeProperty(
|
data class MaterialTypeProperty(
|
||||||
var name: String = "",
|
var name: String = "",
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package dev.fyloz.colorrecipesexplorer.model
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.RestException
|
import dev.fyloz.colorrecipesexplorer.exception.RestException
|
||||||
|
import dev.fyloz.colorrecipesexplorer.utils.months
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
@ -65,37 +66,47 @@ data class ConfigurationImageDto(
|
||||||
|
|
||||||
fun configuration(
|
fun configuration(
|
||||||
type: ConfigurationType,
|
type: ConfigurationType,
|
||||||
content: String,
|
content: String = type.defaultContent.toString(),
|
||||||
lastUpdated: LocalDateTime? = null
|
lastUpdated: LocalDateTime? = null
|
||||||
) = Configuration(type, content, lastUpdated ?: LocalDateTime.now())
|
) = Configuration(type, content, lastUpdated ?: LocalDateTime.now())
|
||||||
|
|
||||||
|
fun configuration(
|
||||||
|
dto: ConfigurationDto
|
||||||
|
) = with(dto) {
|
||||||
|
configuration(type = key.toConfigurationType(), content = content)
|
||||||
|
}
|
||||||
|
|
||||||
enum class ConfigurationType(
|
enum class ConfigurationType(
|
||||||
val key: String,
|
val key: String,
|
||||||
|
val defaultContent: Any? = null,
|
||||||
val computed: Boolean = false,
|
val computed: Boolean = false,
|
||||||
val file: Boolean = false,
|
val file: Boolean = false,
|
||||||
val requireRestart: Boolean = false,
|
val requireRestart: Boolean = false,
|
||||||
val public: Boolean = false
|
val public: Boolean = false,
|
||||||
|
val secure: Boolean = false
|
||||||
) {
|
) {
|
||||||
INSTANCE_NAME("instance.name", public = true),
|
INSTANCE_NAME("instance.name", defaultContent = "Color Recipes Explorer", public = true),
|
||||||
INSTANCE_LOGO_PATH("instance.logo.path", public = true),
|
INSTANCE_LOGO_PATH("instance.logo.path", defaultContent = "images/logo", public = true),
|
||||||
INSTANCE_ICON_PATH("instance.icon.path", public = true),
|
INSTANCE_ICON_PATH("instance.icon.path", defaultContent = "images/icon", public = true),
|
||||||
INSTANCE_URL("instance.url", public = true),
|
INSTANCE_URL("instance.url", "http://localhost:9090", public = true),
|
||||||
|
|
||||||
DATABASE_URL("database.url", file = true, requireRestart = true),
|
DATABASE_URL("database.url", defaultContent = "mysql://localhost/cre", file = true, requireRestart = true),
|
||||||
DATABASE_USER("database.user", file = true, requireRestart = true),
|
DATABASE_USER("database.user", defaultContent = "cre", file = true, requireRestart = true),
|
||||||
DATABASE_PASSWORD("database.password", file = true, requireRestart = true),
|
DATABASE_PASSWORD("database.password", defaultContent = "asecurepassword", file = true, requireRestart = true, secure = true),
|
||||||
DATABASE_SUPPORTED_VERSION("database.version.supported", computed = true),
|
DATABASE_SUPPORTED_VERSION("database.version.supported", computed = true),
|
||||||
|
|
||||||
RECIPE_APPROBATION_EXPIRATION("recipe.approbation.expiration"),
|
RECIPE_APPROBATION_EXPIRATION("recipe.approbation.expiration", defaultContent = 4.months),
|
||||||
|
|
||||||
TOUCH_UP_KIT_CACHE_PDF("touchupkit.pdf.cache"),
|
TOUCH_UP_KIT_CACHE_PDF("touchupkit.pdf.cache", defaultContent = true),
|
||||||
TOUCH_UP_KIT_EXPIRATION("touchupkit.expiration"),
|
TOUCH_UP_KIT_EXPIRATION("touchupkit.expiration", defaultContent = 1.months),
|
||||||
|
|
||||||
EMERGENCY_MODE_ENABLED("env.emergency", computed = true, public = true),
|
EMERGENCY_MODE_ENABLED("env.emergency", computed = true, public = true),
|
||||||
BUILD_VERSION("env.build.version", computed = true),
|
BUILD_VERSION("env.build.version", computed = true),
|
||||||
BUILD_TIME("env.build.time", computed = true),
|
BUILD_TIME("env.build.time", computed = true),
|
||||||
JAVA_VERSION("env.java.version", computed = true),
|
JAVA_VERSION("env.java.version", computed = true),
|
||||||
OPERATING_SYSTEM("env.os", computed = true)
|
OPERATING_SYSTEM("env.os", computed = true),
|
||||||
|
|
||||||
|
GENERATED_ENCRYPTION_SALT("security.salt", file = true, requireRestart = true)
|
||||||
;
|
;
|
||||||
|
|
||||||
override fun toString() = key
|
override fun toString() = key
|
||||||
|
|
|
@ -6,7 +6,7 @@ import dev.fyloz.colorrecipesexplorer.model.ConfigurationImageDto
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.toAuthority
|
import dev.fyloz.colorrecipesexplorer.model.account.toAuthority
|
||||||
import dev.fyloz.colorrecipesexplorer.restartApplication
|
import dev.fyloz.colorrecipesexplorer.restartApplication
|
||||||
import dev.fyloz.colorrecipesexplorer.service.ConfigurationService
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.security.core.Authentication
|
import org.springframework.security.core.Authentication
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
@ -21,12 +21,12 @@ class ConfigurationController(val configurationService: ConfigurationService) {
|
||||||
ok(with(configurationService) {
|
ok(with(configurationService) {
|
||||||
if (keys != null) getAll(keys) else getAll()
|
if (keys != null) getAll(keys) else getAll()
|
||||||
}.filter {
|
}.filter {
|
||||||
authentication.hasAuthority(it)
|
!it.type.secure && authentication.hasAuthority(it)
|
||||||
})
|
})
|
||||||
|
|
||||||
@GetMapping("{key}")
|
@GetMapping("{key}")
|
||||||
fun get(@PathVariable key: String, authentication: Authentication?) = with(configurationService.get(key)) {
|
fun get(@PathVariable key: String, authentication: Authentication?) = with(configurationService.get(key)) {
|
||||||
if (authentication.hasAuthority(this)) ok(this) else forbidden()
|
if (!this.type.secure && authentication.hasAuthority(this)) ok(this) else forbidden()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping
|
@PutMapping
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.rest
|
package dev.fyloz.colorrecipesexplorer.rest
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
||||||
import dev.fyloz.colorrecipesexplorer.service.ConfigurationService
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
import dev.fyloz.colorrecipesexplorer.service.FileService
|
import dev.fyloz.colorrecipesexplorer.service.FileService
|
||||||
import org.springframework.core.io.ByteArrayResource
|
import org.springframework.core.io.ByteArrayResource
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.service
|
package dev.fyloz.colorrecipesexplorer.service
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.config.blacklistedJwtTokens
|
import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens
|
||||||
import dev.fyloz.colorrecipesexplorer.config.defaultGroupCookieName
|
import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.*
|
import dev.fyloz.colorrecipesexplorer.model.account.*
|
||||||
import dev.fyloz.colorrecipesexplorer.model.validation.or
|
import dev.fyloz.colorrecipesexplorer.model.validation.or
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.UserRepository
|
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.GroupRepository
|
import dev.fyloz.colorrecipesexplorer.repository.GroupRepository
|
||||||
|
import dev.fyloz.colorrecipesexplorer.repository.UserRepository
|
||||||
import org.springframework.context.annotation.Lazy
|
import org.springframework.context.annotation.Lazy
|
||||||
import org.springframework.context.annotation.Profile
|
import org.springframework.context.annotation.Profile
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
|
|
|
@ -1,159 +0,0 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.service
|
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.DatabaseUpdaterProperties
|
|
||||||
import dev.fyloz.colorrecipesexplorer.SUPPORTED_DATABASE_VERSION
|
|
||||||
import dev.fyloz.colorrecipesexplorer.config.FileConfiguration
|
|
||||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
|
|
||||||
import dev.fyloz.colorrecipesexplorer.emergencyMode
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.*
|
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.ConfigurationRepository
|
|
||||||
import org.springframework.boot.info.BuildProperties
|
|
||||||
import org.springframework.context.annotation.Lazy
|
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.Period
|
|
||||||
import java.time.ZoneId
|
|
||||||
import javax.annotation.PostConstruct
|
|
||||||
|
|
||||||
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 of the configuration with the given [type]. */
|
|
||||||
fun set(type: ConfigurationType, content: String)
|
|
||||||
|
|
||||||
/** Sets the content of the configuration matching the given [configuration] with a given image. */
|
|
||||||
fun set(configuration: ConfigurationImageDto)
|
|
||||||
}
|
|
||||||
|
|
||||||
const val CONFIGURATION_LOGO_FILE_PATH = "images/logo"
|
|
||||||
const val CONFIGURATION_ICON_FILE_PATH = "images/icon"
|
|
||||||
const val CONFIGURATION_FORMATTED_LIST_DELIMITER = ';'
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class ConfigurationServiceImpl(
|
|
||||||
@Lazy private val repository: ConfigurationRepository,
|
|
||||||
private val fileService: FileService,
|
|
||||||
private val fileConfiguration: FileConfiguration,
|
|
||||||
private val creProperties: CreProperties,
|
|
||||||
private val databaseProperties: DatabaseUpdaterProperties,
|
|
||||||
private val buildInfo: BuildProperties
|
|
||||||
) : ConfigurationService {
|
|
||||||
override fun getAll() =
|
|
||||||
ConfigurationType.values().mapNotNull {
|
|
||||||
try {
|
|
||||||
get(it)
|
|
||||||
} catch (_: ConfigurationNotSetException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAll(formattedKeyList: String) =
|
|
||||||
formattedKeyList.split(CONFIGURATION_FORMATTED_LIST_DELIMITER).map(this::get)
|
|
||||||
|
|
||||||
override fun get(key: String) =
|
|
||||||
get(key.toConfigurationType())
|
|
||||||
|
|
||||||
override fun get(type: ConfigurationType) = when {
|
|
||||||
type.computed -> getComputedConfiguration(type)
|
|
||||||
type.file -> fileConfiguration.get(type)
|
|
||||||
!emergencyMode -> repository.findByIdOrNull(type.key)?.toConfiguration()
|
|
||||||
else -> null
|
|
||||||
} ?: throw ConfigurationNotSetException(type)
|
|
||||||
|
|
||||||
override fun set(configurations: List<ConfigurationDto>) {
|
|
||||||
configurations.forEach(this::set)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun set(configuration: ConfigurationDto) = with(configuration) {
|
|
||||||
set(key.toConfigurationType(), content)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun set(type: ConfigurationType, content: String) {
|
|
||||||
when {
|
|
||||||
type.computed -> throw CannotSetComputedConfigurationException(type)
|
|
||||||
type.file -> fileConfiguration.set(type, content)
|
|
||||||
!emergencyMode -> repository.save(configuration(type, content).toEntity())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
fun initializeProperties() {
|
|
||||||
ConfigurationType.values().filter { !it.computed }.forEach {
|
|
||||||
try {
|
|
||||||
get(it)
|
|
||||||
} catch (_: ConfigurationNotSetException) {
|
|
||||||
set(it, it.defaultContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val ConfigurationType.defaultContent: String
|
|
||||||
get() = when (this) {
|
|
||||||
ConfigurationType.INSTANCE_NAME -> "Color Recipes Explorer"
|
|
||||||
ConfigurationType.INSTANCE_LOGO_PATH -> "images/logo"
|
|
||||||
ConfigurationType.INSTANCE_ICON_PATH -> "images/icon"
|
|
||||||
ConfigurationType.INSTANCE_URL -> creProperties.deploymentUrl
|
|
||||||
ConfigurationType.DATABASE_URL -> databaseProperties.url
|
|
||||||
ConfigurationType.DATABASE_USER -> databaseProperties.username
|
|
||||||
ConfigurationType.DATABASE_PASSWORD -> databaseProperties.password
|
|
||||||
ConfigurationType.RECIPE_APPROBATION_EXPIRATION -> period(months = 4)
|
|
||||||
ConfigurationType.TOUCH_UP_KIT_CACHE_PDF -> "true"
|
|
||||||
ConfigurationType.TOUCH_UP_KIT_EXPIRATION -> period(months = 1)
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getComputedConfiguration(key: ConfigurationType) = configuration(
|
|
||||||
key, when (key) {
|
|
||||||
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 ${key.key} because it is not a computed configuration")
|
|
||||||
}.toString()
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun period(days: Int = 0, months: Int = 0, years: Int = 0) =
|
|
||||||
Period.of(days, months, years).toString()
|
|
||||||
}
|
|
|
@ -3,6 +3,7 @@ package dev.fyloz.colorrecipesexplorer.service
|
||||||
import dev.fyloz.colorrecipesexplorer.model.*
|
import dev.fyloz.colorrecipesexplorer.model.*
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository
|
import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository
|
||||||
import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH
|
import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
import io.jsonwebtoken.lang.Assert
|
import io.jsonwebtoken.lang.Assert
|
||||||
import org.springframework.context.annotation.Lazy
|
import org.springframework.context.annotation.Lazy
|
||||||
import org.springframework.context.annotation.Profile
|
import org.springframework.context.annotation.Profile
|
||||||
|
|
|
@ -4,6 +4,7 @@ import dev.fyloz.colorrecipesexplorer.model.*
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Group
|
import dev.fyloz.colorrecipesexplorer.model.account.Group
|
||||||
import dev.fyloz.colorrecipesexplorer.model.validation.or
|
import dev.fyloz.colorrecipesexplorer.model.validation.or
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
|
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.setAll
|
import dev.fyloz.colorrecipesexplorer.utils.setAll
|
||||||
import org.springframework.context.annotation.Lazy
|
import org.springframework.context.annotation.Lazy
|
||||||
import org.springframework.context.annotation.Profile
|
import org.springframework.context.annotation.Profile
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.service
|
package dev.fyloz.colorrecipesexplorer.service
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
||||||
import dev.fyloz.colorrecipesexplorer.model.touchupkit.*
|
import dev.fyloz.colorrecipesexplorer.model.touchupkit.*
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository
|
import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository
|
||||||
import dev.fyloz.colorrecipesexplorer.rest.TOUCH_UP_KIT_CONTROLLER_PATH
|
import dev.fyloz.colorrecipesexplorer.rest.TOUCH_UP_KIT_CONTROLLER_PATH
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.*
|
import dev.fyloz.colorrecipesexplorer.utils.*
|
||||||
import org.springframework.context.annotation.Profile
|
import org.springframework.context.annotation.Profile
|
||||||
import org.springframework.core.io.ByteArrayResource
|
import org.springframework.core.io.ByteArrayResource
|
||||||
|
@ -29,12 +29,12 @@ interface TouchUpKitService :
|
||||||
/**
|
/**
|
||||||
* Generates and returns a [PdfDocument] for the given [job] as a [ByteArrayResource].
|
* Generates and returns a [PdfDocument] for the given [job] as a [ByteArrayResource].
|
||||||
*
|
*
|
||||||
* If [CreProperties.cacheGeneratedFiles] is enabled and a file exists for the job, its content will be returned.
|
* If TOUCH_UP_KIT_CACHE_PDF is enabled and a file exists for the job, its content will be returned.
|
||||||
* If caching is enabled but no file exists for the job, the generated ByteArrayResource will be cached on the disk.
|
* If caching is enabled but no file exists for the job, the generated ByteArrayResource will be cached on the disk.
|
||||||
*/
|
*/
|
||||||
fun generateJobPdfResource(job: String): ByteArrayResource
|
fun generateJobPdfResource(job: String): ByteArrayResource
|
||||||
|
|
||||||
/** Writes the given [document] to the [FileService] if [CreProperties.cacheGeneratedFiles] is enabled. */
|
/** Writes the given [document] to the [FileService] if TOUCH_UP_KIT_CACHE_PDF is enabled. */
|
||||||
fun String.cachePdfDocument(document: PdfDocument)
|
fun String.cachePdfDocument(document: PdfDocument)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,6 +47,10 @@ class TouchUpKitServiceImpl(
|
||||||
) : AbstractExternalModelService<TouchUpKit, TouchUpKitSaveDto, TouchUpKitUpdateDto, TouchUpKitOutputDto, TouchUpKitRepository>(
|
) : AbstractExternalModelService<TouchUpKit, TouchUpKitSaveDto, TouchUpKitUpdateDto, TouchUpKitOutputDto, TouchUpKitRepository>(
|
||||||
touchUpKitRepository
|
touchUpKitRepository
|
||||||
), TouchUpKitService {
|
), TouchUpKitService {
|
||||||
|
private val cacheGeneratedFiles by lazy {
|
||||||
|
configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF).content == true.toString()
|
||||||
|
}
|
||||||
|
|
||||||
override fun idNotFoundException(id: Long) = touchUpKitIdNotFoundException(id)
|
override fun idNotFoundException(id: Long) = touchUpKitIdNotFoundException(id)
|
||||||
override fun idAlreadyExistsException(id: Long) = touchUpKitIdAlreadyExistsException(id)
|
override fun idAlreadyExistsException(id: Long) = touchUpKitIdAlreadyExistsException(id)
|
||||||
|
|
||||||
|
@ -117,7 +121,7 @@ class TouchUpKitServiceImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun generateJobPdfResource(job: String): ByteArrayResource {
|
override fun generateJobPdfResource(job: String): ByteArrayResource {
|
||||||
if (cacheGeneratedFiles()) {
|
if (cacheGeneratedFiles) {
|
||||||
with(job.pdfDocumentPath()) {
|
with(job.pdfDocumentPath()) {
|
||||||
if (fileService.exists(this)) {
|
if (fileService.exists(this)) {
|
||||||
return fileService.read(this)
|
return fileService.read(this)
|
||||||
|
@ -131,7 +135,7 @@ class TouchUpKitServiceImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun String.cachePdfDocument(document: PdfDocument) {
|
override fun String.cachePdfDocument(document: PdfDocument) {
|
||||||
if (!cacheGeneratedFiles()) return
|
if (!cacheGeneratedFiles) return
|
||||||
|
|
||||||
fileService.write(document.toByteArrayResource(), this.pdfDocumentPath(), true)
|
fileService.write(document.toByteArrayResource(), this.pdfDocumentPath(), true)
|
||||||
}
|
}
|
||||||
|
@ -141,7 +145,4 @@ class TouchUpKitServiceImpl(
|
||||||
|
|
||||||
private fun TouchUpKit.pdfUrl() =
|
private fun TouchUpKit.pdfUrl() =
|
||||||
"${configService.get(ConfigurationType.INSTANCE_URL).content}$TOUCH_UP_KIT_CONTROLLER_PATH/pdf?job=$project"
|
"${configService.get(ConfigurationType.INSTANCE_URL).content}$TOUCH_UP_KIT_CONTROLLER_PATH/pdf?job=$project"
|
||||||
|
|
||||||
private fun cacheGeneratedFiles() =
|
|
||||||
configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF).content == "true"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -36,3 +36,10 @@ fun <T> MutableCollection<T>.setAll(elements: Collection<T>) {
|
||||||
this.clear()
|
this.clear()
|
||||||
this.addAll(elements)
|
this.addAll(elements)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Removes and returns all elements of a [MutableCollection] matching the given [predicate]. */
|
||||||
|
inline fun <T> MutableCollection<T>.excludeAll(predicate: (T) -> Boolean): Iterable<T> {
|
||||||
|
val matching = this.filter(predicate)
|
||||||
|
this.removeAll(matching)
|
||||||
|
return matching
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
package dev.fyloz.colorrecipesexplorer.utils
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.encrypt.Encryptors
|
||||||
|
import org.springframework.security.crypto.encrypt.TextEncryptor
|
||||||
|
|
||||||
|
fun String.encrypt(password: String, salt: String): String =
|
||||||
|
withTextEncryptor(password, salt) {
|
||||||
|
it.encrypt(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.decrypt(password: String, salt: String): String =
|
||||||
|
withTextEncryptor(password, salt) {
|
||||||
|
it.decrypt(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withTextEncryptor(password: String, salt: String, op: (TextEncryptor) -> String) =
|
||||||
|
op(Encryptors.text(password, salt))
|
|
@ -0,0 +1,9 @@
|
||||||
|
package dev.fyloz.colorrecipesexplorer.utils
|
||||||
|
|
||||||
|
import java.time.Period
|
||||||
|
|
||||||
|
fun period(days: Int = 0, months: Int = 0, years: Int = 0): Period =
|
||||||
|
Period.of(days, months, years)
|
||||||
|
|
||||||
|
val Int.months: Period
|
||||||
|
get() = period(months = this)
|
|
@ -1,11 +1,11 @@
|
||||||
# PORT
|
# PORT
|
||||||
server.port=9090
|
server.port=9090
|
||||||
# CRE
|
# CRE
|
||||||
cre.server.working-directory=data
|
cre.server.data-directory=data
|
||||||
cre.server.deployment-url=http://localhost:9090
|
cre.server.config-directory=config
|
||||||
cre.server.cache-generated-files=true
|
|
||||||
cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE
|
cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE
|
||||||
cre.security.jwt-duration=18000000
|
cre.security.jwt-duration=18000000
|
||||||
|
cre.security.aes-secret=blabla
|
||||||
# Root user
|
# Root user
|
||||||
cre.security.root.id=9999
|
cre.security.root.id=9999
|
||||||
cre.security.root.password=password
|
cre.security.root.password=password
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.service
|
package dev.fyloz.colorrecipesexplorer.service
|
||||||
|
|
||||||
import com.nhaarman.mockitokotlin2.*
|
import com.nhaarman.mockitokotlin2.*
|
||||||
import dev.fyloz.colorrecipesexplorer.config.defaultGroupCookieName
|
import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.*
|
import dev.fyloz.colorrecipesexplorer.model.account.*
|
||||||
|
|
|
@ -1,22 +1,26 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.service
|
package dev.fyloz.colorrecipesexplorer.service
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.config.FileConfiguration
|
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||||
import dev.fyloz.colorrecipesexplorer.model.*
|
import dev.fyloz.colorrecipesexplorer.model.*
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.ConfigurationRepository
|
import dev.fyloz.colorrecipesexplorer.service.config.CONFIGURATION_FORMATTED_LIST_DELIMITER
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationServiceImpl
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationSource
|
||||||
|
import dev.fyloz.colorrecipesexplorer.utils.encrypt
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.assertThrows
|
import org.junit.jupiter.api.assertThrows
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class ConfigurationServiceTest {
|
class ConfigurationServiceTest {
|
||||||
private val repository = mockk<ConfigurationRepository>()
|
private val fileService = mockk<FileService>()
|
||||||
private val fileConfiguration = mockk<FileConfiguration>()
|
private val configurationSource = mockk<ConfigurationSource>()
|
||||||
private val service = spyk(ConfigurationServiceImpl(repository, mockk(), fileConfiguration, mockk(), mockk(), mockk()))
|
private val securityProperties = mockk<CreSecurityProperties> {
|
||||||
|
every { configSalt } returns "d32270943af7e1cc"
|
||||||
|
}
|
||||||
|
private val service = spyk(ConfigurationServiceImpl(fileService, configurationSource, securityProperties, mockk()))
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
fun afterEach() {
|
fun afterEach() {
|
||||||
|
@ -126,73 +130,22 @@ class ConfigurationServiceTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `get(type) gets in the repository when the given ConfigurationType is not computed or a file property`() {
|
fun `get(type) gets the configuration in the ConfigurationSource`() {
|
||||||
val type = ConfigurationType.INSTANCE_ICON_PATH
|
val type = ConfigurationType.INSTANCE_ICON_PATH
|
||||||
|
val configuration = configuration(type = type)
|
||||||
|
|
||||||
every { repository.findById(type.key) } returns Optional.of(
|
every { configurationSource.get(type) } returns configuration
|
||||||
ConfigurationEntity(type.key, type.key, LocalDateTime.now())
|
|
||||||
)
|
|
||||||
|
|
||||||
val configuration = service.get(type)
|
val found = service.get(type)
|
||||||
|
|
||||||
assertTrue {
|
assertEquals(configuration, found)
|
||||||
configuration.key == type.key
|
|
||||||
}
|
|
||||||
|
|
||||||
verify {
|
|
||||||
service.get(type)
|
|
||||||
repository.findById(type.key)
|
|
||||||
}
|
|
||||||
confirmVerified(service, repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `get(type) gets in the FileConfiguration when the gien ConfigurationType is a file property`() {
|
|
||||||
val type = ConfigurationType.DATABASE_URL
|
|
||||||
|
|
||||||
every { fileConfiguration.get(type) } returns configuration(type, type.key)
|
|
||||||
|
|
||||||
val configuration = service.get(type)
|
|
||||||
|
|
||||||
assertTrue {
|
|
||||||
configuration.key == type.key
|
|
||||||
}
|
|
||||||
|
|
||||||
verify {
|
|
||||||
service.get(type)
|
|
||||||
fileConfiguration.get(type)
|
|
||||||
}
|
|
||||||
verify(exactly = 0) {
|
|
||||||
repository.findById(type.key)
|
|
||||||
}
|
|
||||||
confirmVerified(service, fileConfiguration, repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `get(type) computes computed properties`() {
|
|
||||||
val type = ConfigurationType.JAVA_VERSION
|
|
||||||
|
|
||||||
val configuration = service.get(type)
|
|
||||||
|
|
||||||
assertTrue {
|
|
||||||
configuration.key == type.key
|
|
||||||
}
|
|
||||||
|
|
||||||
verify {
|
|
||||||
service.get(type)
|
|
||||||
}
|
|
||||||
verify(exactly = 0) {
|
|
||||||
repository.findById(type.key)
|
|
||||||
fileConfiguration.get(type)
|
|
||||||
}
|
|
||||||
confirmVerified(service, repository, fileConfiguration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `get(type) throws ConfigurationNotSetException when the given ConfigurationType has no set configuration`() {
|
fun `get(type) throws ConfigurationNotSetException when the given ConfigurationType has no set configuration`() {
|
||||||
val type = ConfigurationType.INSTANCE_ICON_PATH
|
val type = ConfigurationType.INSTANCE_ICON_PATH
|
||||||
|
|
||||||
every { repository.findById(type.key) } returns Optional.empty()
|
every { configurationSource.get(type) } returns null
|
||||||
|
|
||||||
with(assertThrows<ConfigurationNotSetException> { service.get(type) }) {
|
with(assertThrows<ConfigurationNotSetException> { service.get(type) }) {
|
||||||
assertEquals(type, this.type)
|
assertEquals(type, this.type)
|
||||||
|
@ -200,56 +153,64 @@ class ConfigurationServiceTest {
|
||||||
|
|
||||||
verify {
|
verify {
|
||||||
service.get(type)
|
service.get(type)
|
||||||
repository.findById(type.key)
|
configurationSource.get(type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `set() set the configuration in the FileConfiguration when the given ConfigurationType is a file configuration`() {
|
fun `get(type) throws InvalidConfigurationKeyException when the given ConfigurationType is encryption salt`() {
|
||||||
val type = ConfigurationType.DATABASE_URL
|
val type = ConfigurationType.GENERATED_ENCRYPTION_SALT
|
||||||
val content = "url"
|
|
||||||
|
|
||||||
every { fileConfiguration.set(type, content) } just runs
|
assertThrows<InvalidConfigurationKeyException> { service.get(type) }
|
||||||
|
|
||||||
service.set(type, content)
|
|
||||||
|
|
||||||
verify {
|
|
||||||
service.set(type, content)
|
|
||||||
fileConfiguration.set(type, content)
|
|
||||||
}
|
|
||||||
confirmVerified(service, fileConfiguration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `set() set the configuration in the repository when the given ConfigurationType is not a computed configuration of a file configuration`() {
|
fun `get(type) decrypts configuration content when the given ConfigurationType is secure`() {
|
||||||
val type = ConfigurationType.INSTANCE_ICON_PATH
|
val type = ConfigurationType.DATABASE_PASSWORD
|
||||||
val content = "path"
|
val content = "securepassword"
|
||||||
val configuration = configuration(type, content)
|
val configuration = configuration(
|
||||||
val entity = configuration.toEntity()
|
type = type,
|
||||||
|
content = content.encrypt(type.key, securityProperties.configSalt!!)
|
||||||
|
)
|
||||||
|
|
||||||
every { repository.save(entity) } returns entity
|
every { configurationSource.get(type) } returns configuration
|
||||||
|
|
||||||
service.set(type, content)
|
val found = service.get(type)
|
||||||
|
|
||||||
verify {
|
assertEquals(content, found.content)
|
||||||
service.set(type, content)
|
|
||||||
repository.save(entity)
|
|
||||||
}
|
|
||||||
confirmVerified(service, repository)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `set() throws CannotSetComputedConfigurationException when the given ConfigurationType is a computed configuration`() {
|
fun `set(configuration) set configuration in ConfigurationSource`() {
|
||||||
val type = ConfigurationType.JAVA_VERSION
|
val configuration = configuration(type = ConfigurationType.INSTANCE_NAME)
|
||||||
val content = "5"
|
|
||||||
|
|
||||||
with(assertThrows<CannotSetComputedConfigurationException> { service.set(type, content) }) {
|
every { configurationSource.set(any<Configuration>()) } just runs
|
||||||
assertEquals(type, this.type)
|
|
||||||
}
|
service.set(configuration)
|
||||||
|
|
||||||
verify {
|
verify {
|
||||||
service.set(type, content)
|
configurationSource.set(configuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `set(configuration) encrypts secure configurations`() {
|
||||||
|
val type = ConfigurationType.DATABASE_PASSWORD
|
||||||
|
val content = "securepassword"
|
||||||
|
val encryptedContent =content.encrypt(type.key, securityProperties.configSalt!!)
|
||||||
|
val configuration = configuration(type = type, content = content)
|
||||||
|
|
||||||
|
mockkStatic(String::encrypt)
|
||||||
|
|
||||||
|
every { configurationSource.set(any<Configuration>()) } just runs
|
||||||
|
every { content.encrypt(any(), any()) } returns encryptedContent
|
||||||
|
|
||||||
|
service.set(configuration)
|
||||||
|
|
||||||
|
verify {
|
||||||
|
configurationSource.set(match<Configuration> {
|
||||||
|
it.content == encryptedContent
|
||||||
|
})
|
||||||
}
|
}
|
||||||
confirmVerified(service)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,6 @@ import kotlin.test.assertTrue
|
||||||
|
|
||||||
private val creProperties = CreProperties().apply {
|
private val creProperties = CreProperties().apply {
|
||||||
dataDirectory = "data"
|
dataDirectory = "data"
|
||||||
deploymentUrl = "http://localhost"
|
|
||||||
}
|
}
|
||||||
private const val mockFilePath = "existingFile"
|
private const val mockFilePath = "existingFile"
|
||||||
private val mockFilePathPath = Path.of(mockFilePath)
|
private val mockFilePathPath = Path.of(mockFilePath)
|
|
@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||||
import dev.fyloz.colorrecipesexplorer.model.*
|
import dev.fyloz.colorrecipesexplorer.model.*
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.group
|
import dev.fyloz.colorrecipesexplorer.model.account.group
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
|
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
@ -271,7 +272,7 @@ private class RecipeImageServiceTestContext {
|
||||||
val recipeImagesIds = setOf(1L, 10L, 21L)
|
val recipeImagesIds = setOf(1L, 10L, 21L)
|
||||||
val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet()
|
val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet()
|
||||||
val recipeImagesFiles = recipeImagesNames.map { File(it) }.toTypedArray()
|
val recipeImagesFiles = recipeImagesNames.map { File(it) }.toTypedArray()
|
||||||
val recipeDirectory = spyk(File(recipe.imagesDirectoryPath)) {
|
val recipeDirectory = mockk<File> {
|
||||||
every { exists() } returns true
|
every { exists() } returns true
|
||||||
every { isDirectory } returns true
|
every { isDirectory } returns true
|
||||||
every { listFiles() } returns recipeImagesFiles
|
every { listFiles() } returns recipeImagesFiles
|
||||||
|
|
|
@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
||||||
import dev.fyloz.colorrecipesexplorer.model.configuration
|
import dev.fyloz.colorrecipesexplorer.model.configuration
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository
|
import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository
|
||||||
import dev.fyloz.colorrecipesexplorer.service.*
|
import dev.fyloz.colorrecipesexplorer.service.*
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.PdfDocument
|
import dev.fyloz.colorrecipesexplorer.utils.PdfDocument
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.toByteArrayResource
|
import dev.fyloz.colorrecipesexplorer.utils.toByteArrayResource
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
|
@ -18,9 +19,7 @@ private class TouchUpKitServiceTestContext {
|
||||||
val fileService = mockk<FileService> {
|
val fileService = mockk<FileService> {
|
||||||
every { write(any<ByteArrayResource>(), any(), any()) } just Runs
|
every { write(any<ByteArrayResource>(), any(), any()) } just Runs
|
||||||
}
|
}
|
||||||
val creProperties = mockk<CreProperties> {
|
val creProperties = mockk<CreProperties>()
|
||||||
every { cacheGeneratedFiles } returns false
|
|
||||||
}
|
|
||||||
val configService = mockk<ConfigurationService>(relaxed = true)
|
val configService = mockk<ConfigurationService>(relaxed = true)
|
||||||
val touchUpKitService = spyk(TouchUpKitServiceImpl(fileService, configService, touchUpKitRepository))
|
val touchUpKitService = spyk(TouchUpKitServiceImpl(fileService, configService, touchUpKitRepository))
|
||||||
val pdfDocumentData = mockk<ByteArrayResource>()
|
val pdfDocumentData = mockk<ByteArrayResource>()
|
||||||
|
@ -79,10 +78,13 @@ class TouchUpKitServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun `generateJobPdfResource() returns a cached ByteArrayResource from the FileService when caching is enabled and a cached file eixsts for the given job`() {
|
fun `generateJobPdfResource() returns a cached ByteArrayResource from the FileService when caching is enabled and a cached file eixsts for the given job`() {
|
||||||
test {
|
test {
|
||||||
every { creProperties.cacheGeneratedFiles } returns true
|
enableCachePdf()
|
||||||
every { fileService.exists(any()) } returns true
|
every { fileService.exists(any()) } returns true
|
||||||
every { fileService.read(any()) } returns pdfDocumentData
|
every { fileService.read(any()) } returns pdfDocumentData
|
||||||
every { configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns configuration(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF, "true")
|
every { configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns configuration(
|
||||||
|
ConfigurationType.TOUCH_UP_KIT_CACHE_PDF,
|
||||||
|
"true"
|
||||||
|
)
|
||||||
|
|
||||||
val redResource = touchUpKitService.generateJobPdfResource(job)
|
val redResource = touchUpKitService.generateJobPdfResource(job)
|
||||||
|
|
||||||
|
@ -95,7 +97,7 @@ class TouchUpKitServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun `cachePdfDocument() does nothing when caching is disabled`() {
|
fun `cachePdfDocument() does nothing when caching is disabled`() {
|
||||||
test {
|
test {
|
||||||
every { creProperties.cacheGeneratedFiles } returns false
|
disableCachePdf()
|
||||||
|
|
||||||
with(touchUpKitService) {
|
with(touchUpKitService) {
|
||||||
job.cachePdfDocument(pdfDocument)
|
job.cachePdfDocument(pdfDocument)
|
||||||
|
@ -110,8 +112,7 @@ class TouchUpKitServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun `cachePdfDocument() writes the given document to the FileService when cache is enabled`() {
|
fun `cachePdfDocument() writes the given document to the FileService when cache is enabled`() {
|
||||||
test {
|
test {
|
||||||
every { creProperties.cacheGeneratedFiles } returns true
|
enableCachePdf()
|
||||||
every { configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns configuration(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF, "true")
|
|
||||||
|
|
||||||
with(touchUpKitService) {
|
with(touchUpKitService) {
|
||||||
job.cachePdfDocument(pdfDocument)
|
job.cachePdfDocument(pdfDocument)
|
||||||
|
@ -123,6 +124,19 @@ class TouchUpKitServiceTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun TouchUpKitServiceTestContext.enableCachePdf() =
|
||||||
|
this.setCachePdf(true)
|
||||||
|
|
||||||
|
private fun TouchUpKitServiceTestContext.disableCachePdf() =
|
||||||
|
this.setCachePdf(false)
|
||||||
|
|
||||||
|
private fun TouchUpKitServiceTestContext.setCachePdf(enabled: Boolean) {
|
||||||
|
every { configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns configuration(
|
||||||
|
type = ConfigurationType.TOUCH_UP_KIT_CACHE_PDF,
|
||||||
|
enabled.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun test(test: TouchUpKitServiceTestContext.() -> Unit) {
|
private fun test(test: TouchUpKitServiceTestContext.() -> Unit) {
|
||||||
TouchUpKitServiceTestContext().test()
|
TouchUpKitServiceTestContext().test()
|
||||||
}
|
}
|
Loading…
Reference in New Issue