diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..2008876 --- /dev/null +++ b/.drone.yml @@ -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 ] + + diff --git a/.gitignore b/.gitignore index bb791d1..d512c1f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ gradle/ build/ logs/ -config/ data/ dokka/ dist/ diff --git a/Dockerfile b/Dockerfile index 8d55776..fb252b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,11 @@ FROM openjdk:$JAVA_VERSION WORKDIR /usr/bin/cre/ -ARG ARTIFACT_NAME=ColorRecipesExplorer -COPY $ARTIFACT_NAME.jar ColorRecipesExplorer.jar +ARG CRE_ARTIFACT_NAME=ColorRecipesExplorer +COPY $CRE_ARTIFACT_NAME.jar ColorRecipesExplorer.jar -ARG PORT=9090 -EXPOSE $PORT +ARG CRE_PORT=9090 +EXPOSE $CRE_PORT ENV spring_profiles_active=h2,rest ENV server_port=$PORT diff --git a/build.gradle.kts b/build.gradle.kts index 8c0d274..9912eaf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,12 +2,12 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile group = "dev.fyloz.colorrecipesexplorer" -val kotlinVersion = "1.5.0" +val kotlinVersion = "1.5.21" val springBootVersion = "2.3.4.RELEASE" plugins { // 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" id("java") @@ -46,7 +46,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-devtools:${springBootVersion}") 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("org.junit.jupiter:junit-jupiter-api:5.3.2") testImplementation("io.mockk:mockk:1.10.6") @@ -83,8 +83,8 @@ sourceSets { tasks.test { reports { - junitXml.isEnabled = true - html.isEnabled = false + junitXml.required.set(true) + html.required.set(false) } useJUnitPlatform() @@ -99,7 +99,6 @@ tasks.withType() { tasks.withType().all { kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() - useIR = true freeCompilerArgs = listOf( "-Xopt-in=kotlin.contracts.ExperimentalContracts", "-Xinline-classes" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9c03148 --- /dev/null +++ b/docker-compose.yml @@ -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 + diff --git a/gradle.Dockerfile b/gradle.Dockerfile index edbee03..2538416 100644 --- a/gradle.Dockerfile +++ b/gradle.Dockerfile @@ -1,5 +1,5 @@ ARG JDK_VERSION=11 -ARG GRADLE_VERSION=6.8 +ARG GRADLE_VERSION=7.1 FROM gradle:$GRADLE_VERSION-jdk$JDK_VERSION WORKDIR /usr/src/cre/ diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt index ca54fa3..a355f4b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt @@ -1,20 +1,17 @@ package dev.fyloz.colorrecipesexplorer -import dev.fyloz.colorrecipesexplorer.config.FileConfiguration import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabase -import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabaseException import dev.fyloz.colorrecipesexplorer.databasemanager.databaseContext import dev.fyloz.colorrecipesexplorer.databasemanager.databaseUpdaterProperties import dev.fyloz.colorrecipesexplorer.model.ConfigurationType +import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService import org.slf4j.Logger -import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.jdbc.DataSourceBuilder import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.DependsOn import org.springframework.context.annotation.Profile import org.springframework.core.env.ConfigurableEnvironment -import java.lang.RuntimeException import javax.sql.DataSource const val SUPPORTED_DATABASE_VERSION = 5 @@ -23,21 +20,20 @@ val DATABASE_NAME_REGEX = Regex("(\\w+)$") @Profile("!emergency") @Configuration -@DependsOn("configurationsInitializer") +@DependsOn("configurationsInitializer", "configurationService") class DataSourceConfiguration { @Bean(name = ["dataSource"]) fun customDataSource( logger: Logger, environment: ConfigurableEnvironment, - fileConfiguration: FileConfiguration, - databaseUpdaterProperties: DatabaseUpdaterProperties + configurationService: ConfigurationService ): DataSource { - fun getConfiguration(type: ConfigurationType, defaultProperty: String) = - fileConfiguration.get(type)?.content ?: defaultProperty + fun getConfiguration(type: ConfigurationType) = + configurationService.get(type).content - val databaseUrl = "jdbc:" + getConfiguration(ConfigurationType.DATABASE_URL, databaseUpdaterProperties.url) - val databaseUsername = getConfiguration(ConfigurationType.DATABASE_USER, databaseUpdaterProperties.username) - val databasePassword = getConfiguration(ConfigurationType.DATABASE_PASSWORD, databaseUpdaterProperties.password) + val databaseUrl = "jdbc:" + getConfiguration(ConfigurationType.DATABASE_URL) + val databaseUsername = getConfiguration(ConfigurationType.DATABASE_USER) + val databasePassword = getConfiguration(ConfigurationType.DATABASE_PASSWORD) try { runDatabaseVersionCheck(logger, databaseUrl, DatabaseUpdaterProperties().apply { @@ -158,7 +154,6 @@ fun throwUnsupportedDatabaseVersion(version: Int, logger: Logger) { throw DatabaseVersioningException.UnsupportedDatabaseVersion(version) } -@ConfigurationProperties(prefix = "cre.database") class DatabaseUpdaterProperties { var url: String = "" var username: String = "" diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt similarity index 73% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt index f70db75..353ee71 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt @@ -6,15 +6,16 @@ import dev.fyloz.colorrecipesexplorer.emergencyMode import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES import dev.fyloz.colorrecipesexplorer.restartApplication import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService +import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService import org.slf4j.Logger import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent import org.springframework.boot.context.event.ApplicationReadyEvent -import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.ApplicationListener import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Profile import org.springframework.core.Ordered import org.springframework.core.annotation.Order +import javax.annotation.PostConstruct import kotlin.concurrent.thread @Configuration @@ -22,6 +23,7 @@ import kotlin.concurrent.thread @Profile("!emergency") class ApplicationReadyListener( private val materialTypeService: MaterialTypeService, + private val configurationService: ConfigurationService, private val materialTypeProperties: MaterialTypeProperties, private val creProperties: CreProperties, private val logger: Logger @@ -37,9 +39,29 @@ class ApplicationReadyListener( return } - materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes) + initDatabaseConfigurations() + initMaterialTypes() CRE_PROPERTIES = creProperties } + + private fun initMaterialTypes() { + logger.info("Initializing system material types") + materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes) + } + + private fun initDatabaseConfigurations() { + configurationService.initializeProperties { !it.file } + } +} + +@Configuration("configurationsInitializer") +class ConfigurationsInitializer( + private val configurationService: ConfigurationService +) { + @PostConstruct + fun initializeFileConfigurations() { + configurationService.initializeProperties { it.file } + } } class ApplicationInitializer : ApplicationListener { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ConfigurationsInitializer.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ConfigurationsInitializer.kt deleted file mode 100644 index 0a08037..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ConfigurationsInitializer.kt +++ /dev/null @@ -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" -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/EmergencySecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/EmergencySecurityConfig.kt deleted file mode 100644 index 3b8cd7f..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/EmergencySecurityConfig.kt +++ /dev/null @@ -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()) - } -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt index ed053e7..6deafc6 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt @@ -11,7 +11,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration -@EnableConfigurationProperties(MaterialTypeProperties::class, CreProperties::class, DatabaseUpdaterProperties::class) +@EnableConfigurationProperties(MaterialTypeProperties::class, CreProperties::class) class SpringConfiguration { @Bean fun logger(): Logger = LoggerFactory.getLogger(ColorRecipesExplorerApplication::class.java) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/WebSecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/WebSecurityConfig.kt deleted file mode 100644 index 80e0ae0..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/WebSecurityConfig.kt +++ /dev/null @@ -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 - ) { - 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() - -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) -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt index 0b9c251..aad3e76 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt @@ -1,15 +1,31 @@ package dev.fyloz.colorrecipesexplorer.config.properties import org.springframework.boot.context.properties.ConfigurationProperties +import kotlin.properties.Delegates.notNull const val DEFAULT_DATA_DIRECTORY = "data" const val DEFAULT_CONFIG_DIRECTORY = "config" -const val DEFAULT_DEPLOYMENT_URL = "http://localhost" @ConfigurationProperties(prefix = "cre.server") class CreProperties { var dataDirectory: String = DEFAULT_DATA_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() + var jwtDuration by notNull() + + // Configs + var configSalt: String? = null + + // Users + var root: SystemUserCredentials? = null + + class SystemUserCredentials{ + var id by notNull() + var password by notNull() + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/MaterialTypeProperties.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/MaterialTypeProperties.kt index 728fb75..226e9d3 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/MaterialTypeProperties.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/MaterialTypeProperties.kt @@ -10,7 +10,6 @@ import org.springframework.util.Assert @ConfigurationProperties(prefix = "entities.material-types") class MaterialTypeProperties { var systemTypes: MutableList = mutableListOf() - var baseName: String = "" data class MaterialTypeProperty( var name: String = "", diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt new file mode 100644 index 0000000..bca86e3 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt @@ -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() + +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 + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt new file mode 100644 index 0000000..5440e61 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -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 +) { + 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") +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Configuration.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Configuration.kt index a106fa9..c1c4384 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Configuration.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Configuration.kt @@ -2,6 +2,7 @@ package dev.fyloz.colorrecipesexplorer.model import com.fasterxml.jackson.annotation.JsonIgnore import dev.fyloz.colorrecipesexplorer.exception.RestException +import dev.fyloz.colorrecipesexplorer.utils.months import org.springframework.http.HttpStatus import org.springframework.web.multipart.MultipartFile import java.time.LocalDateTime @@ -65,37 +66,47 @@ data class ConfigurationImageDto( fun configuration( type: ConfigurationType, - content: String, + content: String = type.defaultContent.toString(), lastUpdated: LocalDateTime? = null ) = Configuration(type, content, lastUpdated ?: LocalDateTime.now()) +fun configuration( + dto: ConfigurationDto +) = with(dto) { + configuration(type = key.toConfigurationType(), content = content) +} + enum class ConfigurationType( val key: String, + val defaultContent: Any? = null, val computed: Boolean = false, val file: 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_LOGO_PATH("instance.logo.path", public = true), - INSTANCE_ICON_PATH("instance.icon.path", public = true), - INSTANCE_URL("instance.url", public = true), + INSTANCE_NAME("instance.name", defaultContent = "Color Recipes Explorer", public = true), + INSTANCE_LOGO_PATH("instance.logo.path", defaultContent = "images/logo", public = true), + INSTANCE_ICON_PATH("instance.icon.path", defaultContent = "images/icon", public = true), + INSTANCE_URL("instance.url", "http://localhost:9090", public = true), - DATABASE_URL("database.url", file = true, requireRestart = true), - DATABASE_USER("database.user", file = true, requireRestart = true), - DATABASE_PASSWORD("database.password", file = true, requireRestart = true), + DATABASE_URL("database.url", defaultContent = "mysql://localhost/cre", file = true, requireRestart = true), + DATABASE_USER("database.user", defaultContent = "cre", file = true, requireRestart = true), + DATABASE_PASSWORD("database.password", defaultContent = "asecurepassword", file = true, requireRestart = true, secure = 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_EXPIRATION("touchupkit.expiration"), + TOUCH_UP_KIT_CACHE_PDF("touchupkit.pdf.cache", defaultContent = true), + TOUCH_UP_KIT_EXPIRATION("touchupkit.expiration", defaultContent = 1.months), EMERGENCY_MODE_ENABLED("env.emergency", computed = true, public = true), BUILD_VERSION("env.build.version", computed = true), BUILD_TIME("env.build.time", 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 diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt index a3017c6..1cad0ee 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt @@ -6,7 +6,7 @@ import dev.fyloz.colorrecipesexplorer.model.ConfigurationImageDto import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.toAuthority 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.core.Authentication import org.springframework.web.bind.annotation.* @@ -21,12 +21,12 @@ class ConfigurationController(val configurationService: ConfigurationService) { ok(with(configurationService) { if (keys != null) getAll(keys) else getAll() }.filter { - authentication.hasAuthority(it) + !it.type.secure && authentication.hasAuthority(it) }) @GetMapping("{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 diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt index ded4d51..5f1e689 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt @@ -1,7 +1,7 @@ package dev.fyloz.colorrecipesexplorer.rest 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 org.springframework.core.io.ByteArrayResource import org.springframework.http.MediaType diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt index bab0b70..9373ecb 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt @@ -1,12 +1,12 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.config.blacklistedJwtTokens -import dev.fyloz.colorrecipesexplorer.config.defaultGroupCookieName +import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens +import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.account.* import dev.fyloz.colorrecipesexplorer.model.validation.or -import dev.fyloz.colorrecipesexplorer.repository.UserRepository import dev.fyloz.colorrecipesexplorer.repository.GroupRepository +import dev.fyloz.colorrecipesexplorer.repository.UserRepository import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Profile import org.springframework.security.core.userdetails.UserDetails diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationService.kt deleted file mode 100644 index d6f8eef..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationService.kt +++ /dev/null @@ -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 - - /** - * 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 - - /** - * 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) - - /** - * 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) { - 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() -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt index 6d6aad4..6802776 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt @@ -3,6 +3,7 @@ package dev.fyloz.colorrecipesexplorer.service import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH +import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService import io.jsonwebtoken.lang.Assert import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Profile diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt index 55ee5f0..3b44f8a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt @@ -4,6 +4,7 @@ import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.model.validation.or import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository +import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService import dev.fyloz.colorrecipesexplorer.utils.setAll import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Profile diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitService.kt index 17945f3..acd1600 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitService.kt @@ -1,10 +1,10 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.model.ConfigurationType import dev.fyloz.colorrecipesexplorer.model.touchupkit.* import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository import dev.fyloz.colorrecipesexplorer.rest.TOUCH_UP_KIT_CONTROLLER_PATH +import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService import dev.fyloz.colorrecipesexplorer.utils.* import org.springframework.context.annotation.Profile import org.springframework.core.io.ByteArrayResource @@ -29,12 +29,12 @@ interface TouchUpKitService : /** * 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. */ 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) } @@ -47,6 +47,10 @@ class TouchUpKitServiceImpl( ) : AbstractExternalModelService( touchUpKitRepository ), 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 idAlreadyExistsException(id: Long) = touchUpKitIdAlreadyExistsException(id) @@ -117,7 +121,7 @@ class TouchUpKitServiceImpl( } override fun generateJobPdfResource(job: String): ByteArrayResource { - if (cacheGeneratedFiles()) { + if (cacheGeneratedFiles) { with(job.pdfDocumentPath()) { if (fileService.exists(this)) { return fileService.read(this) @@ -131,7 +135,7 @@ class TouchUpKitServiceImpl( } override fun String.cachePdfDocument(document: PdfDocument) { - if (!cacheGeneratedFiles()) return + if (!cacheGeneratedFiles) return fileService.write(document.toByteArrayResource(), this.pdfDocumentPath(), true) } @@ -141,7 +145,4 @@ class TouchUpKitServiceImpl( private fun TouchUpKit.pdfUrl() = "${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" } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationService.kt new file mode 100644 index 0000000..03eef2f --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationService.kt @@ -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 + + /** + * 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 + + /** + * 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) + + /** + * 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) { + 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 + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationSource.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationSource.kt new file mode 100644 index 0000000..0b00a97 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationSource.kt @@ -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) +} + +@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) { + 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) = + 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) { + 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) { + throw UnsupportedOperationException("Cannot set computed configurations") + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt index c6d4b9c..a7a41ea 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt @@ -36,3 +36,10 @@ fun MutableCollection.setAll(elements: Collection) { this.clear() this.addAll(elements) } + +/** Removes and returns all elements of a [MutableCollection] matching the given [predicate]. */ +inline fun MutableCollection.excludeAll(predicate: (T) -> Boolean): Iterable { + val matching = this.filter(predicate) + this.removeAll(matching) + return matching +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Crypto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Crypto.kt new file mode 100644 index 0000000..a8206e6 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Crypto.kt @@ -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)) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Time.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Time.kt new file mode 100644 index 0000000..9889865 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Time.kt @@ -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) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 69a48e9..e796dda 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,11 +1,11 @@ # PORT server.port=9090 # CRE -cre.server.working-directory=data -cre.server.deployment-url=http://localhost:9090 -cre.server.cache-generated-files=true +cre.server.data-directory=data +cre.server.config-directory=config cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE cre.security.jwt-duration=18000000 +cre.security.aes-secret=blabla # Root user cre.security.root.id=9999 cre.security.root.password=password diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt index 4d15164..a9979b7 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt @@ -1,7 +1,7 @@ package dev.fyloz.colorrecipesexplorer.service 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.NotFoundException import dev.fyloz.colorrecipesexplorer.model.account.* diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt index 5275e97..8c4df85 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt @@ -1,22 +1,26 @@ 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.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 org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows -import java.time.LocalDateTime -import java.util.* import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class ConfigurationServiceTest { - private val repository = mockk() - private val fileConfiguration = mockk() - private val service = spyk(ConfigurationServiceImpl(repository, mockk(), fileConfiguration, mockk(), mockk(), mockk())) + private val fileService = mockk() + private val configurationSource = mockk() + private val securityProperties = mockk { + every { configSalt } returns "d32270943af7e1cc" + } + private val service = spyk(ConfigurationServiceImpl(fileService, configurationSource, securityProperties, mockk())) @AfterEach fun afterEach() { @@ -126,73 +130,22 @@ class ConfigurationServiceTest { } @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 configuration = configuration(type = type) - every { repository.findById(type.key) } returns Optional.of( - ConfigurationEntity(type.key, type.key, LocalDateTime.now()) - ) + every { configurationSource.get(type) } returns configuration - val configuration = service.get(type) + val found = service.get(type) - assertTrue { - 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) + assertEquals(configuration, found) } @Test fun `get(type) throws ConfigurationNotSetException when the given ConfigurationType has no set configuration`() { val type = ConfigurationType.INSTANCE_ICON_PATH - every { repository.findById(type.key) } returns Optional.empty() + every { configurationSource.get(type) } returns null with(assertThrows { service.get(type) }) { assertEquals(type, this.type) @@ -200,56 +153,64 @@ class ConfigurationServiceTest { verify { service.get(type) - repository.findById(type.key) + configurationSource.get(type) } } @Test - fun `set() set the configuration in the FileConfiguration when the given ConfigurationType is a file configuration`() { - val type = ConfigurationType.DATABASE_URL - val content = "url" + fun `get(type) throws InvalidConfigurationKeyException when the given ConfigurationType is encryption salt`() { + val type = ConfigurationType.GENERATED_ENCRYPTION_SALT - every { fileConfiguration.set(type, content) } just runs - - service.set(type, content) - - verify { - service.set(type, content) - fileConfiguration.set(type, content) - } - confirmVerified(service, fileConfiguration) + assertThrows { service.get(type) } } @Test - fun `set() set the configuration in the repository when the given ConfigurationType is not a computed configuration of a file configuration`() { - val type = ConfigurationType.INSTANCE_ICON_PATH - val content = "path" - val configuration = configuration(type, content) - val entity = configuration.toEntity() + fun `get(type) decrypts configuration content when the given ConfigurationType is secure`() { + val type = ConfigurationType.DATABASE_PASSWORD + val content = "securepassword" + val configuration = configuration( + 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 { - service.set(type, content) - repository.save(entity) - } - confirmVerified(service, repository) + assertEquals(content, found.content) } @Test - fun `set() throws CannotSetComputedConfigurationException when the given ConfigurationType is a computed configuration`() { - val type = ConfigurationType.JAVA_VERSION - val content = "5" + fun `set(configuration) set configuration in ConfigurationSource`() { + val configuration = configuration(type = ConfigurationType.INSTANCE_NAME) - with(assertThrows { service.set(type, content) }) { - assertEquals(type, this.type) - } + every { configurationSource.set(any()) } just runs + + service.set(configuration) 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()) } just runs + every { content.encrypt(any(), any()) } returns encryptedContent + + service.set(configuration) + + verify { + configurationSource.set(match { + it.content == encryptedContent + }) } - confirmVerified(service) } } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/FileServiceTest.kt similarity index 99% rename from src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt rename to src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/FileServiceTest.kt index c414c9a..8c4ca7a 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/FileServiceTest.kt @@ -15,7 +15,6 @@ import kotlin.test.assertTrue private val creProperties = CreProperties().apply { dataDirectory = "data" - deploymentUrl = "http://localhost" } private const val mockFilePath = "existingFile" private val mockFilePathPath = Path.of(mockFilePath) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt index 4419e3c..58a1193 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.model.account.group import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository +import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -271,7 +272,7 @@ private class RecipeImageServiceTestContext { val recipeImagesIds = setOf(1L, 10L, 21L) val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet() val recipeImagesFiles = recipeImagesNames.map { File(it) }.toTypedArray() - val recipeDirectory = spyk(File(recipe.imagesDirectoryPath)) { + val recipeDirectory = mockk { every { exists() } returns true every { isDirectory } returns true every { listFiles() } returns recipeImagesFiles diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitServiceTest.kt similarity index 83% rename from src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt rename to src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitServiceTest.kt index c81244a..c913d45 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitServiceTest.kt @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.model.ConfigurationType import dev.fyloz.colorrecipesexplorer.model.configuration import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository import dev.fyloz.colorrecipesexplorer.service.* +import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService import dev.fyloz.colorrecipesexplorer.utils.PdfDocument import dev.fyloz.colorrecipesexplorer.utils.toByteArrayResource import io.mockk.* @@ -18,9 +19,7 @@ private class TouchUpKitServiceTestContext { val fileService = mockk { every { write(any(), any(), any()) } just Runs } - val creProperties = mockk { - every { cacheGeneratedFiles } returns false - } + val creProperties = mockk() val configService = mockk(relaxed = true) val touchUpKitService = spyk(TouchUpKitServiceImpl(fileService, configService, touchUpKitRepository)) val pdfDocumentData = mockk() @@ -79,10 +78,13 @@ class TouchUpKitServiceTest { @Test fun `generateJobPdfResource() returns a cached ByteArrayResource from the FileService when caching is enabled and a cached file eixsts for the given job`() { test { - every { creProperties.cacheGeneratedFiles } returns true + enableCachePdf() every { fileService.exists(any()) } returns true 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) @@ -95,7 +97,7 @@ class TouchUpKitServiceTest { @Test fun `cachePdfDocument() does nothing when caching is disabled`() { test { - every { creProperties.cacheGeneratedFiles } returns false + disableCachePdf() with(touchUpKitService) { job.cachePdfDocument(pdfDocument) @@ -110,8 +112,7 @@ class TouchUpKitServiceTest { @Test fun `cachePdfDocument() writes the given document to the FileService when cache is enabled`() { test { - every { creProperties.cacheGeneratedFiles } returns true - every { configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns configuration(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF, "true") + enableCachePdf() with(touchUpKitService) { 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) { TouchUpKitServiceTestContext().test() }