diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 07cdf1bc..7756b093 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -13,6 +13,8 @@ repositories { dependencies { implementation(project(":domain")) implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("com.auth0:java-jwt:3.18.3") testImplementation(kotlin("test")) } diff --git a/api/src/main/kotlin/com/backgu/amaker/auth/controller/AuthApiController.kt b/api/src/main/kotlin/com/backgu/amaker/auth/controller/AuthApiController.kt new file mode 100644 index 00000000..5dec8b8c --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/auth/controller/AuthApiController.kt @@ -0,0 +1,31 @@ +package com.backgu.amaker.auth.controller + +import com.backgu.amaker.auth.config.AuthConfig +import com.backgu.amaker.auth.dto.JwtTokenResponse +import com.backgu.amaker.auth.service.AuthFacade +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/auth") +class AuthApiController( + val authConfig: AuthConfig, + val authFacade: AuthFacade, +) { + @GetMapping("/oauth/google") + fun googleAuth(): String = authConfig.oauthUrl() + + @GetMapping("/code/google") + fun login( + @RequestParam(name = "code") authorizationCode: String, + @RequestParam(name = "scope", required = false) scope: String, + @RequestParam(name = "authuser", required = false) authUser: String, + @RequestParam(name = "prompt", required = false) prompt: String, + ): ResponseEntity { + val token: JwtTokenResponse = authFacade.googleLogin(authorizationCode) + return ResponseEntity.ok(token) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/auth/controller/AuthController.kt b/api/src/main/kotlin/com/backgu/amaker/auth/controller/AuthController.kt index 9dd8f4f1..392de410 100644 --- a/api/src/main/kotlin/com/backgu/amaker/auth/controller/AuthController.kt +++ b/api/src/main/kotlin/com/backgu/amaker/auth/controller/AuthController.kt @@ -6,7 +6,6 @@ import jakarta.servlet.http.HttpServletResponse import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam @Controller @RequestMapping("/auth") @@ -18,15 +17,4 @@ class AuthController( fun googleAuth(response: HttpServletResponse) { response.sendRedirect(authConfig.oauthUrl()) } - - @GetMapping("/code/google") - fun login( - @RequestParam(name = "code") authorizationCode: String, - @RequestParam(name = "scope", required = false) scope: String, - @RequestParam(name = "authuser", required = false) authUser: String, - @RequestParam(name = "prompt", required = false) prompt: String, - ): String { - authService.googleLogin(authorizationCode) - return "redirect:/" - } } diff --git a/api/src/main/kotlin/com/backgu/amaker/auth/dto/JwtTokenResponse.kt b/api/src/main/kotlin/com/backgu/amaker/auth/dto/JwtTokenResponse.kt new file mode 100644 index 00000000..0cb0c1f6 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/auth/dto/JwtTokenResponse.kt @@ -0,0 +1,12 @@ +package com.backgu.amaker.auth.dto + +import com.backgu.amaker.user.dto.UserDto + +class JwtTokenResponse( + val token: String, + user: UserDto, +) { + val name: String = user.name + val email: String = user.email + val picture: String = user.picture +} diff --git a/api/src/main/kotlin/com/backgu/amaker/auth/dto/GoogleOAuth2AccessTokenDto.kt b/api/src/main/kotlin/com/backgu/amaker/auth/dto/oauth/google/GoogleOAuth2AccessTokenDto.kt similarity index 89% rename from api/src/main/kotlin/com/backgu/amaker/auth/dto/GoogleOAuth2AccessTokenDto.kt rename to api/src/main/kotlin/com/backgu/amaker/auth/dto/oauth/google/GoogleOAuth2AccessTokenDto.kt index 94d3d6d9..ee801af7 100644 --- a/api/src/main/kotlin/com/backgu/amaker/auth/dto/GoogleOAuth2AccessTokenDto.kt +++ b/api/src/main/kotlin/com/backgu/amaker/auth/dto/oauth/google/GoogleOAuth2AccessTokenDto.kt @@ -1,4 +1,4 @@ -package com.backgu.amaker.auth.dto +package com.backgu.amaker.auth.dto.oauth.google import com.fasterxml.jackson.databind.PropertyNamingStrategies import com.fasterxml.jackson.databind.annotation.JsonNaming diff --git a/api/src/main/kotlin/com/backgu/amaker/auth/dto/GoogleUserInfoDto.kt b/api/src/main/kotlin/com/backgu/amaker/auth/dto/oauth/google/GoogleUserInfoDto.kt similarity index 88% rename from api/src/main/kotlin/com/backgu/amaker/auth/dto/GoogleUserInfoDto.kt rename to api/src/main/kotlin/com/backgu/amaker/auth/dto/oauth/google/GoogleUserInfoDto.kt index 9367976b..71f4b158 100644 --- a/api/src/main/kotlin/com/backgu/amaker/auth/dto/GoogleUserInfoDto.kt +++ b/api/src/main/kotlin/com/backgu/amaker/auth/dto/oauth/google/GoogleUserInfoDto.kt @@ -1,4 +1,4 @@ -package com.backgu.amaker.auth.dto +package com.backgu.amaker.auth.dto.oauth.google import com.fasterxml.jackson.databind.PropertyNamingStrategies import com.fasterxml.jackson.databind.annotation.JsonNaming diff --git a/api/src/main/kotlin/com/backgu/amaker/auth/infra/GoogleApiClient.kt b/api/src/main/kotlin/com/backgu/amaker/auth/infra/GoogleApiClient.kt index d57de0c1..690a5a48 100644 --- a/api/src/main/kotlin/com/backgu/amaker/auth/infra/GoogleApiClient.kt +++ b/api/src/main/kotlin/com/backgu/amaker/auth/infra/GoogleApiClient.kt @@ -1,6 +1,6 @@ package com.backgu.amaker.auth.infra -import com.backgu.amaker.auth.dto.GoogleUserInfoDto +import com.backgu.amaker.auth.dto.oauth.google.GoogleUserInfoDto import com.backgu.amaker.config.CaughtHttpExchange import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.service.annotation.GetExchange diff --git a/api/src/main/kotlin/com/backgu/amaker/auth/infra/GoogleOAuthClient.kt b/api/src/main/kotlin/com/backgu/amaker/auth/infra/GoogleOAuthClient.kt index bb10dba9..59616627 100644 --- a/api/src/main/kotlin/com/backgu/amaker/auth/infra/GoogleOAuthClient.kt +++ b/api/src/main/kotlin/com/backgu/amaker/auth/infra/GoogleOAuthClient.kt @@ -1,6 +1,6 @@ package com.backgu.amaker.auth.infra -import com.backgu.amaker.auth.dto.GoogleOAuth2AccessTokenDto +import com.backgu.amaker.auth.dto.oauth.google.GoogleOAuth2AccessTokenDto import com.backgu.amaker.config.CaughtHttpExchange import org.springframework.http.MediaType import org.springframework.web.bind.annotation.RequestParam diff --git a/api/src/main/kotlin/com/backgu/amaker/auth/service/AuthFacade.kt b/api/src/main/kotlin/com/backgu/amaker/auth/service/AuthFacade.kt new file mode 100644 index 00000000..73954188 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/auth/service/AuthFacade.kt @@ -0,0 +1,28 @@ +package com.backgu.amaker.auth.service + +import com.backgu.amaker.auth.dto.JwtTokenResponse +import com.backgu.amaker.auth.dto.oauth.google.GoogleUserInfoDto +import com.backgu.amaker.security.jwt.service.JwtService +import com.backgu.amaker.user.dto.UserCreateDto +import com.backgu.amaker.user.dto.UserDto +import com.backgu.amaker.user.service.UserService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class AuthFacade( + val authService: AuthService, + val jwtService: JwtService, + val userService: UserService, +) { + @Transactional + fun googleLogin(authorizationCode: String): JwtTokenResponse { + val userInfo: GoogleUserInfoDto = authService.googleLogin(authorizationCode) + val savedUser: UserDto = + userService.saveOrGetUser(UserCreateDto(userInfo.name, userInfo.email, userInfo.picture)) + val token: String = jwtService.create(savedUser.id, savedUser.userRole.key) + + return JwtTokenResponse(token, savedUser) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/auth/service/AuthService.kt b/api/src/main/kotlin/com/backgu/amaker/auth/service/AuthService.kt index ff3c3e33..ef7248e1 100644 --- a/api/src/main/kotlin/com/backgu/amaker/auth/service/AuthService.kt +++ b/api/src/main/kotlin/com/backgu/amaker/auth/service/AuthService.kt @@ -1,8 +1,8 @@ package com.backgu.amaker.auth.service import com.backgu.amaker.auth.config.AuthConfig -import com.backgu.amaker.auth.dto.GoogleOAuth2AccessTokenDto -import com.backgu.amaker.auth.dto.GoogleUserInfoDto +import com.backgu.amaker.auth.dto.oauth.google.GoogleOAuth2AccessTokenDto +import com.backgu.amaker.auth.dto.oauth.google.GoogleUserInfoDto import com.backgu.amaker.auth.infra.GoogleApiClient import com.backgu.amaker.auth.infra.GoogleOAuthClient import org.springframework.stereotype.Service @@ -10,11 +10,11 @@ import java.lang.IllegalArgumentException @Service class AuthService( - val googleOAuthClient: GoogleOAuthClient, - val googleApiClient: GoogleApiClient, - val authConfig: AuthConfig, + private val googleOAuthClient: GoogleOAuthClient, + private val googleApiClient: GoogleApiClient, + private val authConfig: AuthConfig, ) { - fun googleLogin(authorizationCode: String): String? { + fun googleLogin(authorizationCode: String): GoogleUserInfoDto { val accessTokenDto: GoogleOAuth2AccessTokenDto = googleOAuthClient.getGoogleOAuth2( authorizationCode, @@ -28,6 +28,6 @@ class AuthService( googleApiClient.getUserInfo(accessTokenDto.getBearerToken()) ?: throw IllegalArgumentException("Failed to get user information") - return userInfo.email + return userInfo } } diff --git a/api/src/main/kotlin/com/backgu/amaker/security/JwtAccessDeniedHandler.kt b/api/src/main/kotlin/com/backgu/amaker/security/JwtAccessDeniedHandler.kt new file mode 100644 index 00000000..14434c4a --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/JwtAccessDeniedHandler.kt @@ -0,0 +1,19 @@ +package com.backgu.amaker.security + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component + +@Component +class JwtAccessDeniedHandler : AccessDeniedHandler { + override fun handle( + request: HttpServletRequest?, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException?, + ) { + // TODO 후에 에러 폼이 수정되면 다시 작성 + response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException?.message) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthentication.kt b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthentication.kt new file mode 100644 index 00000000..48b03bbd --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthentication.kt @@ -0,0 +1,8 @@ +package com.backgu.amaker.security + +import java.util.UUID + +class JwtAuthentication( + val id: UUID, + val token: String, +) diff --git a/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationEntryPoint.kt b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationEntryPoint.kt new file mode 100644 index 00000000..ed104404 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationEntryPoint.kt @@ -0,0 +1,19 @@ +package com.backgu.amaker.security + +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 JwtAuthenticationEntryPoint : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest?, + response: HttpServletResponse?, + authException: AuthenticationException?, + ) { + // TODO 후에 에러 폼이 수정되면 다시 작성 + response?.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException?.message) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationProvider.kt b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationProvider.kt new file mode 100644 index 00000000..dfd68bbc --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationProvider.kt @@ -0,0 +1,32 @@ +package com.backgu.amaker.security + +import com.backgu.amaker.user.domain.UserRole +import com.backgu.amaker.user.dto.UserDto +import com.backgu.amaker.user.service.UserService +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.AuthorityUtils.createAuthorityList +import org.springframework.util.ClassUtils + +class JwtAuthenticationProvider( + private val userService: UserService, +) : AuthenticationProvider { + override fun supports(authentication: Class<*>?): Boolean = + ClassUtils.isAssignable(JwtAuthenticationToken::class.java, authentication!!) + + override fun authenticate(authentication: Authentication): Authentication = + processOAuthAuthentication( + authentication.principal.toString(), + ) + + private fun processOAuthAuthentication(email: String): Authentication { + val user: UserDto = userService.getByEmail(email) + val authenticated: JwtAuthenticationToken = + JwtAuthenticationToken( + JwtAuthentication(user.id, user.name), + createAuthorityList(UserRole.USER.key), + ) + authenticated.details = user + return authenticated + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationToken.kt b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationToken.kt new file mode 100644 index 00000000..a2a345ce --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationToken.kt @@ -0,0 +1,32 @@ +package com.backgu.amaker.security + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority + +class JwtAuthenticationToken : AbstractAuthenticationToken { + private val principal: JwtAuthentication + + constructor(principal: JwtAuthentication) : super(null) { + super.setAuthenticated(false) + this.principal = principal + } + + internal constructor(principal: JwtAuthentication, authorities: Collection?) : super( + authorities, + ) { + super.setAuthenticated(true) + this.principal = principal + } + + override fun getPrincipal(): JwtAuthentication = principal + + override fun getCredentials(): String = "" + + override fun setAuthenticated(isAuthenticated: Boolean) { + super.setAuthenticated(false) + } + + override fun eraseCredentials() { + super.eraseCredentials() + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationTokenFilter.kt b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationTokenFilter.kt new file mode 100644 index 00000000..b6d3c302 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/JwtAuthenticationTokenFilter.kt @@ -0,0 +1,92 @@ +package com.backgu.amaker.security + +import com.backgu.amaker.security.jwt.service.JwtService +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.web.filter.OncePerRequestFilter +import java.io.UnsupportedEncodingException +import java.net.URLDecoder +import java.util.Arrays +import java.util.Objects +import java.util.UUID +import java.util.regex.Pattern +import java.util.stream.Collectors + +class JwtAuthenticationTokenFilter( + private val jwtService: JwtService, +) : OncePerRequestFilter() { + private val bearerRegex: Pattern = Pattern.compile("^Bearer$", Pattern.CASE_INSENSITIVE) + private val headerKey: String = "Authorization" + + override fun doFilterInternal( + req: HttpServletRequest, + res: HttpServletResponse, + chain: FilterChain, + ) { + if (SecurityContextHolder.getContext().authentication == null) { + val authorizationToken: String? = obtainAuthorizationToken(req) + try { + if (authorizationToken != null) { + println(authorizationToken) + val claims: JwtService.Claims = jwtService.verify(authorizationToken) + + val id: UUID = UUID.fromString(claims.id.replace("\"", "")) + val authorities: List = obtainAuthorities(claims) + + if (Objects.nonNull(id) && authorities.isNotEmpty()) { + val authentication: JwtAuthenticationToken = + JwtAuthenticationToken( + JwtAuthentication(id, authorizationToken), + authorities, + ) + authentication.details = WebAuthenticationDetailsSource().buildDetails(req) + SecurityContextHolder.getContext().authentication = authentication + } + } + } catch (e: Exception) { + // TODO exception handling + throw IllegalArgumentException("Invalid token") + } + } + chain.doFilter(req, res) + } + + private fun obtainAuthorizationToken(request: HttpServletRequest): String? { + var token: String? = request.getHeader(headerKey) + if (token != null) { + try { + token = URLDecoder.decode(token, "UTF-8") + val parts: Array = token.split(" ".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (parts.size == 2) { + val scheme = parts[0] + val credentials = parts[1] + return if (bearerRegex.matcher(scheme).matches()) credentials else null + } + } catch (_: UnsupportedEncodingException) { + // TODO exception handling + } + } + + return null + } + + private fun obtainAuthorities(claims: JwtService.Claims): List { + val roles: Array = claims.roles + return if (roles.isEmpty()) { + emptyList() + } else { + Arrays + .stream(roles) + .map { role: String? -> + SimpleGrantedAuthority( + role, + ) + }.collect(Collectors.toList()) + } + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/security/config/JwtSecurityConfig.kt b/api/src/main/kotlin/com/backgu/amaker/security/config/JwtSecurityConfig.kt new file mode 100644 index 00000000..1ea1a6b1 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/config/JwtSecurityConfig.kt @@ -0,0 +1,24 @@ +package com.backgu.amaker.security.config + +import com.backgu.amaker.security.JwtAccessDeniedHandler +import com.backgu.amaker.security.JwtAuthenticationProvider +import com.backgu.amaker.security.JwtAuthenticationTokenFilter +import com.backgu.amaker.security.jwt.service.JwtService +import com.backgu.amaker.user.service.UserService +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JwtSecurityConfig( + private var jwtService: JwtService, + private var userService: UserService, +) { + @Bean + fun jwtAuthenticationFilter(): JwtAuthenticationTokenFilter = JwtAuthenticationTokenFilter(jwtService) + + @Bean + fun jwtAuthenticationProvider(): JwtAuthenticationProvider = JwtAuthenticationProvider(userService) + + @Bean + fun jwtAccessDeniedHandler(): JwtAccessDeniedHandler = JwtAccessDeniedHandler() +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/backgu/amaker/security/config/SecurityConfig.kt b/api/src/main/kotlin/com/backgu/amaker/security/config/SecurityConfig.kt new file mode 100644 index 00000000..f0078eb5 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/config/SecurityConfig.kt @@ -0,0 +1,48 @@ +package com.backgu.amaker.security.config + +import com.backgu.amaker.security.JwtAccessDeniedHandler +import com.backgu.amaker.security.JwtAuthenticationEntryPoint +import com.backgu.amaker.security.JwtAuthenticationTokenFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.access.ExceptionTranslationFilter +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val jwtAuthenticationTokenFilter: JwtAuthenticationTokenFilter, + private val jwtAccessDeniedHandler: JwtAccessDeniedHandler, + private val jwtAuthenticationEntryPoint: JwtAuthenticationEntryPoint, +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { + it.disable() + }.sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + }.authorizeHttpRequests { + it + .requestMatchers("/auth/**", "/api/v1/auth/**", "/error") + .permitAll() + .anyRequest() + .authenticated() + }.addFilterBefore(jwtAuthenticationTokenFilter, ExceptionTranslationFilter::class.java) + .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter::class.java) + .exceptionHandling { + it.authenticationEntryPoint(jwtAuthenticationEntryPoint) + it.accessDeniedHandler(jwtAccessDeniedHandler) + }.httpBasic { + it.disable() + }.anonymous { + it.disable() + } + + return http.build() + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/security/jwt/config/JwtConfig.kt b/api/src/main/kotlin/com/backgu/amaker/security/jwt/config/JwtConfig.kt new file mode 100644 index 00000000..d41ee180 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/jwt/config/JwtConfig.kt @@ -0,0 +1,21 @@ +package com.backgu.amaker.security.jwt.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import java.util.Date + +@Configuration +class JwtConfig { + @Value("\${jwt.issuer}") + lateinit var issuer: String + + @Value("\${jwt.client-secret}") + lateinit var clientSecret: String + + @Value("\${jwt.expiration:3600}") + var expiration: Int = 0 + + fun getExpirationMillis(current: Long): Long = current + expiration * 1000L + + fun getExpirationDate(current: Date): Date = Date(getExpirationMillis(current.time)) +} diff --git a/api/src/main/kotlin/com/backgu/amaker/security/jwt/service/JwtService.kt b/api/src/main/kotlin/com/backgu/amaker/security/jwt/service/JwtService.kt new file mode 100644 index 00000000..47dcc236 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/security/jwt/service/JwtService.kt @@ -0,0 +1,65 @@ +package com.backgu.amaker.security.jwt.service + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTCreator +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.interfaces.DecodedJWT +import com.backgu.amaker.security.jwt.config.JwtConfig +import org.springframework.stereotype.Service +import java.util.Date +import java.util.UUID + +@Service +class JwtService( + private var jwtConfig: JwtConfig, +) { + private val jwtHashAlgorithm: Algorithm = Algorithm.HMAC256(jwtConfig.clientSecret) + private val jwtVerifier = JWT.require(jwtHashAlgorithm).withIssuer(jwtConfig.issuer).build() + + fun create( + userId: UUID, + role: String, + ): String = this.create(Claims.of(userId, role)) + + fun create(claims: Claims): String { + val now = Date() + val builder: JWTCreator.Builder = JWT.create() + builder.withIssuer(jwtConfig.issuer) + builder.withIssuedAt(now) + builder.withExpiresAt(jwtConfig.getExpirationDate(now)) + builder.withClaim("id", claims.id.toString()) + builder.withArrayClaim("roles", claims.roles) + println(jwtHashAlgorithm.name) + return builder.sign(jwtHashAlgorithm) + } + + fun verify(token: String?): Claims = Claims(jwtVerifier.verify(token)) + + class Claims { + lateinit var id: String + lateinit var roles: Array + lateinit var issuedAt: Date + lateinit var expiresAt: Date + + private constructor() + + internal constructor(decodedJWT: DecodedJWT) { + this.id = decodedJWT.getClaim("id").toString() + this.roles = decodedJWT.getClaim("roles").asArray(String::class.java) ?: arrayOf() + this.issuedAt = decodedJWT.issuedAt + this.expiresAt = decodedJWT.expiresAt + } + + companion object { + fun of( + id: UUID, + role: String, + ): Claims { + val claims = Claims() + claims.id = id.toString() + claims.roles = arrayOf(role) + return claims + } + } + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/user/dto/UserCreateDto.kt b/api/src/main/kotlin/com/backgu/amaker/user/dto/UserCreateDto.kt new file mode 100644 index 00000000..f58c5fe1 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/user/dto/UserCreateDto.kt @@ -0,0 +1,20 @@ +package com.backgu.amaker.user.dto + +import com.backgu.amaker.user.domain.User + +class UserCreateDto( + name: String?, + email: String?, + picture: String?, +) { + var name: String = name ?: "" + var email: String = email ?: "" + var picture: String = picture ?: "" + + fun toEntity(): User = + User( + name = name, + email = email, + picture = picture, + ) +} diff --git a/api/src/main/kotlin/com/backgu/amaker/user/dto/UserDto.kt b/api/src/main/kotlin/com/backgu/amaker/user/dto/UserDto.kt new file mode 100644 index 00000000..712f5253 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/user/dto/UserDto.kt @@ -0,0 +1,24 @@ +package com.backgu.amaker.user.dto + +import com.backgu.amaker.user.domain.User +import com.backgu.amaker.user.domain.UserRole +import java.util.UUID + +class UserDto( + val id: UUID, + val name: String, + val email: String, + val picture: String, + val userRole: UserRole, +) { + companion object { + fun of(user: User): UserDto = + UserDto( + id = user.id, + name = user.name, + email = user.email, + picture = user.picture, + userRole = user.userRole, + ) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/user/dto/UserRequest.kt b/api/src/main/kotlin/com/backgu/amaker/user/dto/UserRequest.kt deleted file mode 100644 index 5756d88e..00000000 --- a/api/src/main/kotlin/com/backgu/amaker/user/dto/UserRequest.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.backgu.amaker.user.dto - -class UserRequest( - val name: String, - val email: String, - val picture: String, -) diff --git a/api/src/main/kotlin/com/backgu/amaker/user/service/UserService.kt b/api/src/main/kotlin/com/backgu/amaker/user/service/UserService.kt index 41addf25..98191137 100644 --- a/api/src/main/kotlin/com/backgu/amaker/user/service/UserService.kt +++ b/api/src/main/kotlin/com/backgu/amaker/user/service/UserService.kt @@ -1,11 +1,10 @@ package com.backgu.amaker.user.service -import com.backgu.amaker.user.domain.User -import com.backgu.amaker.user.dto.UserRequest +import com.backgu.amaker.user.dto.UserCreateDto +import com.backgu.amaker.user.dto.UserDto import com.backgu.amaker.user.repository.UserRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.UUID @Service @Transactional(readOnly = true) @@ -13,13 +12,15 @@ class UserService( val userRepository: UserRepository, ) { @Transactional - fun saveUser(request: UserRequest): UUID = - userRepository - .save( - User( - name = request.name, - email = request.email, - picture = request.picture, - ), - ).id + fun saveUser(create: UserCreateDto): UserDto = + UserDto.of( + userRepository.save(create.toEntity()), + ) + + fun saveOrGetUser(user: UserCreateDto): UserDto = + userRepository.findByEmail(user.email)?.let { UserDto.of(it) } + ?: saveUser(user) + + fun getByEmail(email: String): UserDto = + UserDto.of(userRepository.findByEmail(email) ?: throw IllegalArgumentException("User not found")) } diff --git a/api/src/main/kotlin/com/backgu/amaker/workspace/controller/WorkspaceController.kt b/api/src/main/kotlin/com/backgu/amaker/workspace/controller/WorkspaceController.kt index 4df54c7b..72a0e65b 100644 --- a/api/src/main/kotlin/com/backgu/amaker/workspace/controller/WorkspaceController.kt +++ b/api/src/main/kotlin/com/backgu/amaker/workspace/controller/WorkspaceController.kt @@ -1,7 +1,10 @@ package com.backgu.amaker.workspace.controller +import com.backgu.amaker.security.JwtAuthentication import com.backgu.amaker.workspace.dto.WorkspaceCreateDto import com.backgu.amaker.workspace.service.WorkspaceService +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -14,9 +17,7 @@ class WorkspaceController( ) { @PostMapping("/workspaces") fun createWorkspace( + @AuthenticationPrincipal token: JwtAuthentication, @RequestBody workspaceCreateDto: WorkspaceCreateDto, - ) { - // TODO : 로그인한 사용자의 아이디를 가져와서 넣어줘야함 - workspaceService.createWorkspace(workspaceCreateDto) - } + ): ResponseEntity = ResponseEntity.ok().body(workspaceService.createWorkspace(token.id, workspaceCreateDto)) } diff --git a/api/src/main/kotlin/com/backgu/amaker/workspace/dto/WorkspaceCreateDto.kt b/api/src/main/kotlin/com/backgu/amaker/workspace/dto/WorkspaceCreateDto.kt index eb146cd0..387fa903 100644 --- a/api/src/main/kotlin/com/backgu/amaker/workspace/dto/WorkspaceCreateDto.kt +++ b/api/src/main/kotlin/com/backgu/amaker/workspace/dto/WorkspaceCreateDto.kt @@ -1,8 +1,5 @@ package com.backgu.amaker.workspace.dto -import java.util.UUID - data class WorkspaceCreateDto( - val userId: UUID, - val name: String, + var name: String = "", ) diff --git a/api/src/main/kotlin/com/backgu/amaker/workspace/service/WorkspaceService.kt b/api/src/main/kotlin/com/backgu/amaker/workspace/service/WorkspaceService.kt index d8a15618..3d32469c 100644 --- a/api/src/main/kotlin/com/backgu/amaker/workspace/service/WorkspaceService.kt +++ b/api/src/main/kotlin/com/backgu/amaker/workspace/service/WorkspaceService.kt @@ -17,6 +17,7 @@ import jakarta.persistence.EntityNotFoundException import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.util.UUID private val logger = KotlinLogging.logger {} @@ -30,11 +31,14 @@ class WorkspaceService( private val chatRoomUserRepository: ChatRoomUserRepository, ) { @Transactional - fun createWorkspace(request: WorkspaceCreateDto): Long { + fun createWorkspace( + userId: UUID, + request: WorkspaceCreateDto, + ): Long { val user = - userRepository.findByIdOrNull(request.userId) ?: run { - logger.error { "User not found : ${request.userId}" } - throw EntityNotFoundException("User not found : ${request.userId}") + userRepository.findByIdOrNull(userId) ?: run { + logger.error { "User not found : $userId" } + throw EntityNotFoundException("User not found : $userId") } val workspace = diff --git a/api/src/main/resources/application.yaml b/api/src/main/resources/application.yaml index 75123434..77740046 100644 --- a/api/src/main/resources/application.yaml +++ b/api/src/main/resources/application.yaml @@ -14,14 +14,21 @@ spring: database: mysql hibernate: ddl-auto: create + open-in-view: false oauth: google: client-id: ${CLIENT_ID} client-secret: ${CLIENT_SECRET} - redirect-uri: "http://localhost:8080/auth/code/google" + redirect-uri: "http://localhost:8080/api/v1/auth/code/google" base-url: "https://accounts.google.com/o/oauth2/auth" oauth-url: "https://oauth2.googleapis.com" api-url: "https://www.googleapis.com" client-name: Google scope: email,profile + + +jwt: + client-secret: ${JWT_SECRET} + expiration: 86400000 + issuer: a-maker diff --git a/api/src/test/kotlin/com/backgu/amaker/auth/service/AuthServiceTest.kt b/api/src/test/kotlin/com/backgu/amaker/auth/service/AuthServiceTest.kt index c29591d9..182b3544 100644 --- a/api/src/test/kotlin/com/backgu/amaker/auth/service/AuthServiceTest.kt +++ b/api/src/test/kotlin/com/backgu/amaker/auth/service/AuthServiceTest.kt @@ -30,7 +30,7 @@ class AuthServiceTest { val result = authService.googleLogin("authCode") // then - assertThat(result).isEqualTo(email) + assertThat(result.email).isEqualTo(email) } @Test diff --git a/api/src/test/kotlin/com/backgu/amaker/auth/test/FailedFakeGoogleApiClient.kt b/api/src/test/kotlin/com/backgu/amaker/auth/test/FailedFakeGoogleApiClient.kt index feebc7bc..2776194f 100644 --- a/api/src/test/kotlin/com/backgu/amaker/auth/test/FailedFakeGoogleApiClient.kt +++ b/api/src/test/kotlin/com/backgu/amaker/auth/test/FailedFakeGoogleApiClient.kt @@ -1,6 +1,6 @@ package com.backgu.amaker.auth.test -import com.backgu.amaker.auth.dto.GoogleUserInfoDto +import com.backgu.amaker.auth.dto.oauth.google.GoogleUserInfoDto import com.backgu.amaker.auth.infra.GoogleApiClient class FailedFakeGoogleApiClient : GoogleApiClient { diff --git a/api/src/test/kotlin/com/backgu/amaker/auth/test/FailedFakeGoogleOAuthClient.kt b/api/src/test/kotlin/com/backgu/amaker/auth/test/FailedFakeGoogleOAuthClient.kt index 9af2585d..563e4e98 100644 --- a/api/src/test/kotlin/com/backgu/amaker/auth/test/FailedFakeGoogleOAuthClient.kt +++ b/api/src/test/kotlin/com/backgu/amaker/auth/test/FailedFakeGoogleOAuthClient.kt @@ -1,6 +1,6 @@ package com.backgu.amaker.auth.test -import com.backgu.amaker.auth.dto.GoogleOAuth2AccessTokenDto +import com.backgu.amaker.auth.dto.oauth.google.GoogleOAuth2AccessTokenDto import com.backgu.amaker.auth.infra.GoogleOAuthClient class FailedFakeGoogleOAuthClient : GoogleOAuthClient { diff --git a/api/src/test/kotlin/com/backgu/amaker/auth/test/SuccessfulStubGoogleApiClient.kt b/api/src/test/kotlin/com/backgu/amaker/auth/test/SuccessfulStubGoogleApiClient.kt index e527e524..f188df94 100644 --- a/api/src/test/kotlin/com/backgu/amaker/auth/test/SuccessfulStubGoogleApiClient.kt +++ b/api/src/test/kotlin/com/backgu/amaker/auth/test/SuccessfulStubGoogleApiClient.kt @@ -1,6 +1,6 @@ package com.backgu.amaker.auth.test -import com.backgu.amaker.auth.dto.GoogleUserInfoDto +import com.backgu.amaker.auth.dto.oauth.google.GoogleUserInfoDto import com.backgu.amaker.auth.infra.GoogleApiClient class SuccessfulStubGoogleApiClient( diff --git a/api/src/test/kotlin/com/backgu/amaker/auth/test/SuccessfulStubGoogleOAuthClient.kt b/api/src/test/kotlin/com/backgu/amaker/auth/test/SuccessfulStubGoogleOAuthClient.kt index e7d312f9..f5a413fa 100644 --- a/api/src/test/kotlin/com/backgu/amaker/auth/test/SuccessfulStubGoogleOAuthClient.kt +++ b/api/src/test/kotlin/com/backgu/amaker/auth/test/SuccessfulStubGoogleOAuthClient.kt @@ -1,6 +1,6 @@ package com.backgu.amaker.auth.test -import com.backgu.amaker.auth.dto.GoogleOAuth2AccessTokenDto +import com.backgu.amaker.auth.dto.oauth.google.GoogleOAuth2AccessTokenDto import com.backgu.amaker.auth.infra.GoogleOAuthClient class SuccessfulStubGoogleOAuthClient : GoogleOAuthClient { diff --git a/api/src/test/kotlin/com/backgu/amaker/fixture/UserFixture.kt b/api/src/test/kotlin/com/backgu/amaker/fixture/UserFixture.kt index 78dd2164..7f18a287 100644 --- a/api/src/test/kotlin/com/backgu/amaker/fixture/UserFixture.kt +++ b/api/src/test/kotlin/com/backgu/amaker/fixture/UserFixture.kt @@ -1,7 +1,7 @@ package com.backgu.amaker.fixture import com.backgu.amaker.user.domain.User -import com.backgu.amaker.user.dto.UserRequest +import com.backgu.amaker.user.dto.UserCreateDto import java.util.UUID class UserFixture { @@ -9,7 +9,7 @@ class UserFixture { val defaultUserId = UUID.fromString("00000000-0000-0000-0000-000000000000") fun createUserRequest() = - UserRequest( + UserCreateDto( name = "name", email = "email", picture = "picture", diff --git a/api/src/test/kotlin/com/backgu/amaker/fixture/WorkspaceFixture.kt b/api/src/test/kotlin/com/backgu/amaker/fixture/WorkspaceFixture.kt index f1475af7..9a6a12d0 100644 --- a/api/src/test/kotlin/com/backgu/amaker/fixture/WorkspaceFixture.kt +++ b/api/src/test/kotlin/com/backgu/amaker/fixture/WorkspaceFixture.kt @@ -1,13 +1,11 @@ package com.backgu.amaker.fixture import com.backgu.amaker.workspace.dto.WorkspaceCreateDto -import java.util.UUID class WorkspaceFixture { companion object { - fun createWorkspaceRequest(userId: UUID) = + fun createWorkspaceRequest() = WorkspaceCreateDto( - userId = userId, name = "name", ) } diff --git a/api/src/test/kotlin/com/backgu/amaker/workspace/service/WorkspaceServiceTest.kt b/api/src/test/kotlin/com/backgu/amaker/workspace/service/WorkspaceServiceTest.kt index 6b315660..d441a21f 100644 --- a/api/src/test/kotlin/com/backgu/amaker/workspace/service/WorkspaceServiceTest.kt +++ b/api/src/test/kotlin/com/backgu/amaker/workspace/service/WorkspaceServiceTest.kt @@ -44,10 +44,10 @@ class WorkspaceServiceTest { @DisplayName("워크 스페이스 생성 테스트") fun createWorkspace() { // given - val request = createWorkspaceRequest(UserFixture.defaultUserId) + val request = createWorkspaceRequest() // when - val result = workspaceService.createWorkspace(request) + val result = workspaceService.createWorkspace(UserFixture.defaultUserId, request) // then assertThat(result).isNotNull @@ -70,11 +70,11 @@ class WorkspaceServiceTest { @DisplayName("유저를 찾을 수 없을 때 워크스페이스 생성 실패") fun createWorkspace_UserNotFound() { // given - val request = createWorkspaceRequest(UUID.randomUUID()) + val request = createWorkspaceRequest() // when & then assertThrows { - workspaceService.createWorkspace(request) + workspaceService.createWorkspace(UUID.randomUUID(), request) } } diff --git a/api/src/test/resources/application.yaml b/api/src/test/resources/application.yaml index 71017fa5..12cc7978 100644 --- a/api/src/test/resources/application.yaml +++ b/api/src/test/resources/application.yaml @@ -25,3 +25,8 @@ oauth: api-url: f client-name: Google scope: email,profile + +jwt: + client-secret: 1234 + expiration: 86400000 + issuer: a-maker