diff --git a/build.gradle.kts b/build.gradle.kts index 1979ec2..8c57eda 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { runtimeOnly("com.microsoft.sqlserver:mssql-jdbc:9.2.1.jre11") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2") } springBoot { diff --git a/gradlew b/gradlew index 4f906e0..744e882 100755 --- a/gradlew +++ b/gradlew @@ -72,7 +72,7 @@ case "`uname`" in Darwin* ) darwin=true ;; - MINGW* ) + MSYS* | MINGW* ) msys=true ;; NONSTOP* ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/TypeAliases.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/TypeAliases.kt index 3dcb7b4..b56a00d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/TypeAliases.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/TypeAliases.kt @@ -1,3 +1,5 @@ package dev.fyloz.colorrecipesexplorer -public typealias SpringUser = org.springframework.security.core.userdetails.User +typealias SpringUser = org.springframework.security.core.userdetails.User +typealias SpringUserDetails = org.springframework.security.core.userdetails.UserDetails +typealias SpringUserDetailsService = org.springframework.security.core.userdetails.UserDetailsService diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt index d859f17..2ded760 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt @@ -1,21 +1,19 @@ package dev.fyloz.colorrecipesexplorer.config.security import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import dev.fyloz.colorrecipesexplorer.SpringUser import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.exception.NotFoundException +import dev.fyloz.colorrecipesexplorer.model.account.UserDetails import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest import dev.fyloz.colorrecipesexplorer.utils.buildJwt +import dev.fyloz.colorrecipesexplorer.utils.parseJwt import io.jsonwebtoken.ExpiredJwtException -import io.jsonwebtoken.Jwts 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.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 javax.servlet.FilterChain import javax.servlet.http.HttpServletRequest @@ -46,10 +44,11 @@ class JwtAuthenticationFilter( request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain, - authResult: Authentication + auth: Authentication ) { - val user = (authResult.principal as SpringUser) - val token = user.buildJwt(securityProperties) + val userDetails = (auth.principal as UserDetails) + val token = + userDetails.user.buildJwt(securityProperties.jwtSecret, duration = securityProperties.jwtDuration).token var bearerCookie = "$authorizationCookieName=Bearer$token; Max-Age=${securityProperties.jwtDuration / 1000}; HttpOnly; SameSite=strict" @@ -59,11 +58,13 @@ class JwtAuthenticationFilter( bearerCookie ) response.addHeader(authorizationCookieName, "Bearer $token") + + updateUserLoginTime(userDetails.user.id) } } class JwtAuthorizationFilter( - private val securityConfigurationProperties: CreSecurityProperties, + private val securityProperties: CreSecurityProperties, authenticationManager: AuthenticationManager, private val loadUserById: (Long) -> UserDetails ) : BasicAuthenticationFilter(authenticationManager) { @@ -99,16 +100,10 @@ class JwtAuthorizationFilter( } 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 + with(parseJwt(token.replace("Bearer", ""), securityProperties.jwtSecret)) { + getAuthenticationToken(this.subject) + } } catch (_: ExpiredJwtException) { 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 index 72fcbd0..6835dc2 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -4,6 +4,8 @@ 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.model.account.UserDetails +import dev.fyloz.colorrecipesexplorer.model.account.user import dev.fyloz.colorrecipesexplorer.service.CreUserDetailsService import dev.fyloz.colorrecipesexplorer.service.UserService import org.slf4j.Logger @@ -22,7 +24,6 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur 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 @@ -34,7 +35,6 @@ 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") @@ -122,8 +122,6 @@ class EmergencySecurityConfig( private val securityProperties: CreSecurityProperties, private val environment: Environment ) : WebSecurityConfigurerAdapter() { - private val rootUserRole = Permission.ADMIN.name - init { emergencyMode = true } @@ -142,7 +140,7 @@ class EmergencySecurityConfig( auth.inMemoryAuthentication() .withUser(securityProperties.root!!.id.toString()) .password(passwordEncoder().encode(securityProperties.root!!.password)) - .authorities(SimpleGrantedAuthority(rootUserRole)) + .authorities(SimpleGrantedAuthority(Permission.ADMIN.name)) } override fun configure(http: HttpSecurity) { @@ -172,11 +170,11 @@ class EmergencySecurityConfig( private fun loadUserById(id: Long): UserDetails { assertRootUserNotNull(securityProperties) if (id == securityProperties.root!!.id) { - return SpringUser( - id.toString(), - securityProperties.root!!.password, - listOf(SimpleGrantedAuthority(rootUserRole)) - ) + return UserDetails(user( + id = id, + password = securityProperties.root!!.password, + permissions = mutableSetOf(Permission.ADMIN) + )) } throw UsernameNotFoundException(id.toString()) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt index 3f4a64a..8f0559a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt @@ -1,5 +1,6 @@ package dev.fyloz.colorrecipesexplorer.model.account +import dev.fyloz.colorrecipesexplorer.SpringUserDetails import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.EntityDto @@ -59,9 +60,6 @@ data class User( .apply { if (group != null) this.addAll(group!!.flatPermissions) } - - val authorities: Set - get() = flatPermissions.map { it.toAuthority() }.toMutableSet() } open class UserSaveDto( @@ -110,6 +108,19 @@ data class UserOutputDto( data class UserLoginRequest(val id: Long, val password: String) +data class UserDetails(val user: User) : SpringUserDetails { + override fun getPassword() = user.password + override fun getUsername() = user.id.toString() + + override fun getAuthorities() = + user.flatPermissions.map { it.toAuthority() }.toMutableSet() + + override fun isAccountNonExpired() = true + override fun isAccountNonLocked() = true + override fun isCredentialsNonExpired() = true + override fun isEnabled() = true +} + // ==== DSL ==== fun user( passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt index 9373ecb..fcb0122 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt @@ -1,5 +1,6 @@ package dev.fyloz.colorrecipesexplorer.service +import dev.fyloz.colorrecipesexplorer.SpringUserDetailsService import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName import dev.fyloz.colorrecipesexplorer.exception.NotFoundException @@ -9,8 +10,6 @@ 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 -import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service @@ -19,7 +18,6 @@ import java.time.LocalDateTime import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import javax.transaction.Transactional -import org.springframework.security.core.userdetails.User as SpringUser interface UserService : ExternalModelService { @@ -69,7 +67,7 @@ interface GroupService : fun setResponseDefaultGroup(groupId: Long, response: HttpServletResponse) } -interface CreUserDetailsService : UserDetailsService { +interface CreUserDetailsService : SpringUserDetailsService { /** Loads an [User] for the given [id]. */ fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean = false): UserDetails } @@ -304,8 +302,7 @@ class GroupServiceImpl( @Profile("!emergency") class CreUserDetailsServiceImpl( private val userService: UserService -) : - CreUserDetailsService { +) : CreUserDetailsService { override fun loadUserByUsername(username: String): UserDetails { try { return loadUserById(username.toLong(), true) @@ -322,6 +319,6 @@ class CreUserDetailsServiceImpl( ignoreDefaultGroupUsers = ignoreDefaultGroupUsers, ignoreSystemUsers = false ) - return SpringUser(user.id.toString(), user.password, user.authorities) + return UserDetails(user) } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt index 446dbf0..a430fb3 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Jwt.kt @@ -1,33 +1,53 @@ package dev.fyloz.colorrecipesexplorer.utils -import dev.fyloz.colorrecipesexplorer.SpringUser -import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.model.account.User import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm import io.jsonwebtoken.io.Encoders import io.jsonwebtoken.security.Keys import java.util.* +import javax.crypto.SecretKey data class Jwt( val subject: String, val secret: String, - val duration: Long, + val duration: Long? = null, val signatureAlgorithm: SignatureAlgorithm = SignatureAlgorithm.HS512 -) +) { + val token: String by lazy { + val builder = Jwts.builder() + .setSubject(subject) -fun SpringUser.buildJwt(properties: CreSecurityProperties) = - Jwt(this.username, properties.jwtSecret, properties.jwtDuration).build() + duration?.let { + val expirationMs = System.currentTimeMillis() + it + val expirationDate = Date(expirationMs) -fun Jwt.build(): String { - val expirationMs = System.currentTimeMillis() + this.duration - val expirationDate = Date(expirationMs) + builder.setExpiration(expirationDate) + } - val base64Secret = Encoders.BASE64.encode(this.secret.toByteArray()) - val key = Keys.hmacShaKeyFor(base64Secret.toByteArray()) - - return Jwts.builder() - .setSubject(this.subject) - .setExpiration(expirationDate) - .signWith(key) - .compact() + builder + .signWith(keyFromSecret(secret)) + .compact() + } } + +/** Build a [Jwt] for the given [User]. */ +fun User.buildJwt(secret: String, duration: Long?) = + Jwt(this.id.toString(), secret, duration) + +/** Parses the given [jwt] string. */ +fun parseJwt(jwt: String, secret: String) = + with( + Jwts.parserBuilder() + .setSigningKey(keyFromSecret(secret)) + .build() + .parseClaimsJws(jwt) + ) { + Jwt(this.body.subject, secret) + } + +/** Creates a base64 encoded [SecretKey] from the given [secret]. */ +private fun keyFromSecret(secret: String) = + with(Encoders.BASE64.encode(secret.toByteArray())) { + Keys.hmacShaKeyFor(this.toByteArray()) + } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e796dda..18852ab 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,7 +3,7 @@ server.port=9090 # CRE cre.server.data-directory=data cre.server.config-directory=config -cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE +cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE78WWOgl8InrbwBgQsMy0 cre.security.jwt-duration=18000000 cre.security.aes-secret=blabla # Root user