diff --git a/README.md b/README.md index ec7e497..2f671ea 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ REST API to support a community scavenger hunt app. +## Environment variables +* `DB_PASSWORD` Password for the database +* `DB_URL` JDBC URL for the database +* `DB_USER` Username for the database +* `JWT_SECRET` Secret pass for the JWT + ## TODO: ### User Endpoints * list teams for hunt GET /hunt/{id}/team diff --git a/build.gradle.kts b/build.gradle.kts index dc00826..aa6e7f4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,15 +30,20 @@ dependencies { val mysqlConnectorJ = "9.5.0" val commonsValidator = "1.10.1" val jakartaValidation = "3.1.1" + val jsonWebToken = "0.13.0" implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-security") implementation("jakarta.validation:jakarta.validation-api:${jakartaValidation}") implementation("com.mysql:mysql-connector-j:${mysqlConnectorJ}") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("commons-validator:commons-validator:${commonsValidator}") + implementation("io.jsonwebtoken:jjwt-api:${jsonWebToken}") + implementation("io.jsonwebtoken:jjwt-impl:${jsonWebToken}") + implementation("io.jsonwebtoken:jjwt-jackson:${jsonWebToken}") developmentOnly("org.springframework.boot:spring-boot-devtools") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") testImplementation("org.springframework.boot:spring-boot-starter-actuator-test") diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthEntrypointJwt.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthEntrypointJwt.kt new file mode 100644 index 0000000..a6c2855 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthEntrypointJwt.kt @@ -0,0 +1,18 @@ +package net.halfbinary.scavengerhuntapi.config + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component + +@Component +class AuthEntrypointJwt: AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthTokenFilter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthTokenFilter.kt new file mode 100644 index 0000000..ceaa08d --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthTokenFilter.kt @@ -0,0 +1,49 @@ +package net.halfbinary.scavengerhuntapi.config + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import net.halfbinary.scavengerhuntapi.service.HunterDetailsService +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + + +@Component +class AuthTokenFilter(private val jwtUtils: JwtUtil, private val hunterDetailsService: HunterDetailsService): OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + try { + val jwt: String? = parseJwt(request) + if (jwt != null && jwtUtils.validateJwtToken(jwt)) { + val username = jwtUtils.getUsernameFromToken(jwt) + val userDetails: UserDetails = hunterDetailsService.loadUserByUsername(username) + val authentication = + UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.authorities + ) + authentication.details = WebAuthenticationDetailsSource().buildDetails(request) + SecurityContextHolder.getContext().authentication = authentication + } + } catch (e: Exception) { + println("Cannot set user authentication: $e") + } + filterChain.doFilter(request, response) + } + + private fun parseJwt(request: HttpServletRequest): String? { + val headerAuth = request.getHeader("Authorization") + if (headerAuth != null && headerAuth.startsWith("Bearer ")) { + return headerAuth.substring(7) + } + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/JwtUtil.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/JwtUtil.kt new file mode 100644 index 0000000..7657318 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/JwtUtil.kt @@ -0,0 +1,62 @@ +package net.halfbinary.scavengerhuntapi.config + +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import jakarta.annotation.PostConstruct +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.Date +import javax.crypto.SecretKey + +@Component +class JwtUtil { + @Value($$"${jwt.secret}") + private val jwtSecret: String? = null + + @Value($$"${jwt.expiration}") + private val jwtExpirationMs = 0 + + private var key: SecretKey? = null + + // Initializes the key after the class is instantiated and the jwtSecret is injected, + // preventing the repeated creation of the key and enhancing performance + @PostConstruct + fun init() { + this.key = Jwts.SIG.HS256.key().build() + } + + // Generate JWT token + fun generateToken(email: String): String { + return Jwts.builder() + .subject(email) + .issuedAt(Date()) + .expiration(Date(System.currentTimeMillis() + jwtExpirationMs)) + .signWith(key) + .compact() + } + + // Get username from JWT token + fun getUsernameFromToken(token: String): String { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .payload + .subject + } + + // Validate JWT token + fun validateJwtToken(token: String?): Boolean { + try { + Jwts.parser().verifyWith(key).build().parseSignedClaims(token) + return true + } catch (e: SecurityException) { + println("Invalid JWT signature: " + e.message) + } catch (e: JwtException) { + println("Invalid JWT token: " + e.message) + } catch (e: IllegalArgumentException) { + println("JWT claims string is empty: " + e.message) + } + return false + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/SecurityConfig.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/SecurityConfig.kt new file mode 100644 index 0000000..18660a0 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/SecurityConfig.kt @@ -0,0 +1,73 @@ +package net.halfbinary.scavengerhuntapi.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.CorsConfigurer +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer +import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer +import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + + +@Configuration +//@EnableWebSecurity +class SecurityConfig(private val authEntrypointJwt: AuthEntrypointJwt, + private val authTokenFilter: AuthTokenFilter) { + + @Bean + fun authenticationJwtTokenFilter(): AuthTokenFilter { + return authTokenFilter + } + + @Bean + @Throws(Exception::class) + fun authenticationManager( + authenticationConfiguration: AuthenticationConfiguration + ): AuthenticationManager? { + return authenticationConfiguration.getAuthenticationManager() + } + + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } + + @Bean + @Throws(Exception::class) + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { + // Updated configuration for Spring Security 6.x + http + .csrf { csrf: CsrfConfigurer -> csrf.disable() } // Disable CSRF + .cors { cors: CorsConfigurer -> cors.disable() } // Disable CORS (or configure if needed) + .exceptionHandling { exceptionHandling: ExceptionHandlingConfigurer -> + exceptionHandling.authenticationEntryPoint( + authEntrypointJwt + ) + } + .sessionManagement { sessionManagement: SessionManagementConfigurer -> + sessionManagement.sessionCreationPolicy( + SessionCreationPolicy.STATELESS + ) + } + .authorizeHttpRequests { authorizeRequests -> + authorizeRequests + .requestMatchers("/auth/**", "/signup") + .permitAll() + .anyRequest().authenticated() + } + + // Add the JWT Token filter before the UsernamePasswordAuthenticationFilter + http.addFilterBefore( + authenticationJwtTokenFilter(), + UsernamePasswordAuthenticationFilter::class.java + ) + return http.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AuthController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AuthController.kt new file mode 100644 index 0000000..a0d7e6c --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AuthController.kt @@ -0,0 +1,53 @@ +package net.halfbinary.scavengerhuntapi.controller + +import jakarta.servlet.http.HttpServletResponse +import jakarta.validation.Valid +import net.halfbinary.scavengerhuntapi.config.JwtUtil +import net.halfbinary.scavengerhuntapi.model.converter.toDomain +import net.halfbinary.scavengerhuntapi.model.request.LoginRequest +import net.halfbinary.scavengerhuntapi.model.request.LogoutRequest +import net.halfbinary.scavengerhuntapi.model.request.RefreshRequest +import net.halfbinary.scavengerhuntapi.model.response.LoginResponse +import net.halfbinary.scavengerhuntapi.service.LoginService +import net.halfbinary.scavengerhuntapi.service.RefreshTokenService +import org.springframework.http.ResponseEntity +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.Collections + + +@RestController +@RequestMapping("/auth") +class AuthController(private val loginService: LoginService, private val jwtUtils: JwtUtil, private val refreshTokenService: RefreshTokenService) { + @PostMapping("/login") + fun login(@Valid @RequestBody body: LoginRequest, response: HttpServletResponse): ResponseEntity { + val result = loginService.login(body.toDomain()) + // TODO: Figure out how to use the authorities + val hunterAuthorities = + if (result.isAdmin) { + SimpleGrantedAuthority("ROLE_ADMIN") + } else { + SimpleGrantedAuthority("ROLE_USER") + } + val user = User(result.email, result.password, Collections.singleton(hunterAuthorities)) + val accessToken = jwtUtils.generateToken(result.email) + val refreshToken = refreshTokenService.generateRefreshToken(result.email) + val loginResponse = LoginResponse(accessToken, refreshToken) + return ResponseEntity.ok(loginResponse) + } + + @PostMapping("/refresh") + fun refresh(@RequestBody body: RefreshRequest): String { + return refreshTokenService.getAccessToken(body.refreshToken) + } + + @PostMapping("/logout") + fun logout(@RequestBody body: LogoutRequest, response: HttpServletResponse): ResponseEntity { + refreshTokenService.removeToken(body.refreshToken) + return ResponseEntity.ok().build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt deleted file mode 100644 index a021c45..0000000 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt +++ /dev/null @@ -1,36 +0,0 @@ -package net.halfbinary.scavengerhuntapi.controller - -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletResponse -import jakarta.validation.Valid -import net.halfbinary.scavengerhuntapi.model.converter.toDomain -import net.halfbinary.scavengerhuntapi.model.converter.toLoginResponse -import net.halfbinary.scavengerhuntapi.model.request.LoginRequest -import net.halfbinary.scavengerhuntapi.model.response.LoginResponse -import net.halfbinary.scavengerhuntapi.service.LoginService -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RestController -import java.net.URLEncoder - - -@RestController -class LoginController(private val loginService: LoginService) { - @PostMapping("/login") - fun login(@Valid @RequestBody body: LoginRequest, response: HttpServletResponse): ResponseEntity { - val result = loginService.login(body.toDomain()) - val creds = "${result.email}|${result.name}" - val encodedCreds = URLEncoder.encode(creds, "UTF-8") - response.addCookie(Cookie("creds", encodedCreds)) - return ResponseEntity.ok(result.toLoginResponse()) - } - - @PostMapping("/logout") - fun logout(response: HttpServletResponse): ResponseEntity { - val cookie = Cookie("creds", null) - cookie.maxAge = 0 - response.addCookie(cookie) - return ResponseEntity.ok("OK") - } -} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt index ed93cd5..a4c3397 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt @@ -4,6 +4,7 @@ import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.error.exception.PreexistingAccountException +import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.validation.FieldError @@ -15,7 +16,9 @@ import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class ExceptionHandler { - + companion object { + private val log = LoggerFactory.getLogger(net.halfbinary.scavengerhuntapi.error.ExceptionHandler::class.java) + } @ExceptionHandler(PreexistingAccountException::class) @ResponseStatus(HttpStatus.CONFLICT) fun preexistingAccountException(e: PreexistingAccountException): String? { @@ -42,8 +45,16 @@ class ExceptionHandler { @ExceptionHandler(HttpMessageNotReadableException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun httpMessageNotReadableException(e: HttpMessageNotReadableException): String? { - return e.message + fun httpMessageNotReadableException(e: HttpMessageNotReadableException): Map { + if (e.message?.contains("body is missing")?:false) { + return simpleMap("body","Body is missing") + } + if (e.message?.contains("parameter")?:false) { + val missingParameter = e.message?.split("parameter ")[1] + return simpleMap(missingParameter?:"","Missing required parameter $missingParameter") + } + log.debug("JSON parsing issue", e) + return simpleMap("body", "Parsing error") } @ExceptionHandler(MethodArgumentNotValidException::class) @@ -56,4 +67,8 @@ class ExceptionHandler { ) } } + + private fun simpleMap(key: String, value: String?): Map { + return mapOf(Pair(key, value)) + } } \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/ExpiredRefreshTokenException.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/ExpiredRefreshTokenException.kt new file mode 100644 index 0000000..1902456 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/ExpiredRefreshTokenException.kt @@ -0,0 +1,5 @@ +package net.halfbinary.scavengerhuntapi.error.exception + +import net.halfbinary.scavengerhuntapi.model.RefreshId + +class ExpiredRefreshTokenException(token: RefreshId): RuntimeException("The refresh token $token is expired.") \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/InvalidRefreshTokenException.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/InvalidRefreshTokenException.kt new file mode 100644 index 0000000..9f2e1cb --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/InvalidRefreshTokenException.kt @@ -0,0 +1,5 @@ +package net.halfbinary.scavengerhuntapi.error.exception + +import net.halfbinary.scavengerhuntapi.model.RefreshId + +class InvalidRefreshTokenException(token: RefreshId): RuntimeException("The refresh token $token is not valid.") \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt index b944653..589453e 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt @@ -6,4 +6,5 @@ typealias FoundId = UUID typealias HuntId = UUID typealias HunterId = UUID typealias ItemId = UUID -typealias TeamId = UUID \ No newline at end of file +typealias TeamId = UUID +typealias RefreshId = UUID \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterConverter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterConverter.kt index eb189b2..c82ac14 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterConverter.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterConverter.kt @@ -3,7 +3,6 @@ package net.halfbinary.scavengerhuntapi.model.converter import net.halfbinary.scavengerhuntapi.model.domain.Hunter import net.halfbinary.scavengerhuntapi.model.record.HunterRecord import net.halfbinary.scavengerhuntapi.model.request.HunterSignupRequest -import net.halfbinary.scavengerhuntapi.model.response.LoginResponse fun HunterSignupRequest.toDomain(): Hunter { return Hunter( @@ -20,8 +19,4 @@ fun Hunter.toRecord(): HunterRecord { fun HunterRecord.toDomain(): Hunter { return Hunter(id, email, name, password, isAdmin) -} - -fun Hunter.toLoginResponse(): LoginResponse { - return LoginResponse(email, name) } \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/RefreshTokenRecord.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/RefreshTokenRecord.kt new file mode 100644 index 0000000..ce492bc --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/RefreshTokenRecord.kt @@ -0,0 +1,16 @@ +package net.halfbinary.scavengerhuntapi.model.record + +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import net.halfbinary.scavengerhuntapi.model.RefreshId +import java.time.LocalDateTime + +@Entity +@Table(name = "refresh_token") +data class RefreshTokenRecord( + @Id + val token: RefreshId, + val email: String, + val expiryDateTime: LocalDateTime +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LogoutRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LogoutRequest.kt new file mode 100644 index 0000000..a5ac952 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LogoutRequest.kt @@ -0,0 +1,9 @@ +package net.halfbinary.scavengerhuntapi.model.request + +import jakarta.validation.constraints.NotBlank +import net.halfbinary.scavengerhuntapi.model.RefreshId + +data class LogoutRequest( + @field:NotBlank(message = "You must provide a refresh token.") + val refreshToken: RefreshId +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/RefreshRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/RefreshRequest.kt new file mode 100644 index 0000000..d9698c1 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/RefreshRequest.kt @@ -0,0 +1,9 @@ +package net.halfbinary.scavengerhuntapi.model.request + +import jakarta.validation.constraints.NotBlank +import net.halfbinary.scavengerhuntapi.model.RefreshId + +data class RefreshRequest( + @field:NotBlank(message = "Refresh token cannot be blank") + val refreshToken: RefreshId, +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/LoginResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/LoginResponse.kt index c98dc89..5ce4073 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/LoginResponse.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/LoginResponse.kt @@ -1,6 +1,8 @@ package net.halfbinary.scavengerhuntapi.model.response +import net.halfbinary.scavengerhuntapi.model.RefreshId + data class LoginResponse( - val email: String, - val name: String + val accessToken: String, + val refreshToken: RefreshId ) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/RefreshTokenRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/RefreshTokenRepository.kt new file mode 100644 index 0000000..15e3d7e --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/RefreshTokenRepository.kt @@ -0,0 +1,7 @@ +package net.halfbinary.scavengerhuntapi.repository + +import net.halfbinary.scavengerhuntapi.model.RefreshId +import net.halfbinary.scavengerhuntapi.model.record.RefreshTokenRecord +import org.springframework.data.jpa.repository.JpaRepository + +interface RefreshTokenRepository: JpaRepository \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HunterDetailsService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HunterDetailsService.kt new file mode 100644 index 0000000..bc4e7ea --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HunterDetailsService.kt @@ -0,0 +1,32 @@ +package net.halfbinary.scavengerhuntapi.service + +import net.halfbinary.scavengerhuntapi.repository.HunterRepository +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.stereotype.Service +import java.util.Collections + + +@Service +class HunterDetailsService(private val hunterRepository: HunterRepository): UserDetailsService { + override fun loadUserByUsername(username: String): UserDetails { + hunterRepository.findByEmail(username) + ?.let { hunter -> + val hunterAuthorities = + if (hunter.isAdmin) { + SimpleGrantedAuthority("ROLE_ADMIN") + } else { + SimpleGrantedAuthority("ROLE_USER") + } + return User( + hunter.email, + hunter.password, + Collections.singleton(hunterAuthorities) + ) + } + throw UsernameNotFoundException("User Not Found with username: $username") + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/RefreshTokenService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/RefreshTokenService.kt new file mode 100644 index 0000000..5897a14 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/RefreshTokenService.kt @@ -0,0 +1,49 @@ +package net.halfbinary.scavengerhuntapi.service + +import net.halfbinary.scavengerhuntapi.config.JwtUtil +import net.halfbinary.scavengerhuntapi.error.exception.ExpiredRefreshTokenException +import net.halfbinary.scavengerhuntapi.error.exception.InvalidRefreshTokenException +import net.halfbinary.scavengerhuntapi.model.RefreshId +import net.halfbinary.scavengerhuntapi.model.record.RefreshTokenRecord +import net.halfbinary.scavengerhuntapi.repository.RefreshTokenRepository +import org.slf4j.LoggerFactory +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@Service +class RefreshTokenService(private val refreshTokenRepository: RefreshTokenRepository, private val jwtUtil: JwtUtil) { + + companion object { + private val log = LoggerFactory.getLogger(RefreshTokenService::class.java) + } + + fun getAccessToken(tokenId: RefreshId): String { + return getToken(tokenId)?.let { refreshToken -> + if (isTokenExpired(refreshToken)) { + removeToken(tokenId) + throw ExpiredRefreshTokenException(tokenId) + } else { + jwtUtil.generateToken(refreshToken.email) + } + }?: throw InvalidRefreshTokenException(tokenId) + } + + fun generateRefreshToken(email: String): RefreshId { + return refreshTokenRepository.save(RefreshTokenRecord(RefreshId.randomUUID(), email, LocalDateTime.now().plus(1, ChronoUnit.MONTHS))).token + } + + fun isTokenExpired(token: RefreshTokenRecord): Boolean { + return token.expiryDateTime.isBefore(LocalDateTime.now()) + } + + fun getToken(token: RefreshId): RefreshTokenRecord? { + return refreshTokenRepository.findByIdOrNull(token) + } + + fun removeToken(token: RefreshId) { + log.debug("Removing refresh token: $token") + refreshTokenRepository.deleteById(token) + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5c5b534..0801a2e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -7,4 +7,7 @@ spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=CHAR spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver spring.datasource.url=${DB_URL} spring.datasource.username=${DB_USER} -spring.datasource.password=${DB_PASSWORD} \ No newline at end of file +spring.datasource.password=${DB_PASSWORD} + +jwt.secret=${JWT_SECRET} +jwt.expiration=30000 \ No newline at end of file