Skip to content

Commit

Permalink
OAuth2 기반 소셜 로그인 추가하기 (#23)
Browse files Browse the repository at this point in the history
* Feat: Security가 적용된 이후 기본적으로 api 실행이 가능한 상태를 유지하기 위해, 우선은 apiEndpoint를 permitAll로 설정

* Feat: 헤더로부터 Jwt를 추출하고 파싱하여 인증을 하기위한 필터 구성

* Feat: 사용자의 권한 관리를 위해 user_roles 추가

* Fix: nullpointException 관련 문제로 필터 작동 및 수정된 포인트 결제가 제대로 수행되지 않던 문제 수정

* Feat: Jwt 필터 처리중에 발생한 에러를 처리하기위한 JwtExceptionFilter 구현

* Feat: Jwt 인증필터와 SpringSecurity의 충돌을 피하기위해, Jwt 인증 필터와 예외 필터, securityFilterChain에 추가

* Feat: 필터를 순회하는 도중에 발생하는 인가 관련 에러를 잡아내기 위한 AccessDeniedHandler, AuthenticationEntryPoint 구현

* Chore: 시스템 환경에 따라 유연하게 그리고 민감한 정보를 public repository에 드러내지 않게 하기위해 환경변수 파일로 대체

* Fix: 트래킹이 안되고 있는 파일 수정

* Feat: oauth2 시큐리티 설정 추가

* Feat: 상이해질 수 있는 서비스 제공자의 사용자 모델의 일관된 표준을 지정하기위해, ProviderUser 모델 구현

* Feat: ProviderUser를 기준으로 Naver 로그인과 Google OAuth2에 호환되는 사용자 데이터 모델링 구성

* Refactor: 소셜 로그인 중 신규 회원가입을 수행하기 위해 기존 User 엔티티에 email 추가

* Feat: OAuth2 인가 서비스를 사용할 Service 클래스 구성

1. 소셜 로그인 이후, OAuth2UserRequest를 통해 인가 서버로부터 받아온 AccessToken 및 OAuth2 서비즈 제공자 식별 데이터 및 attribute 수신
2. providerConverter를 통해, OAuth2 서비스 제공자에 맞는 ProviderUser 생성
3. ProviderUser 조회 후, 서버에 등록된 사용자가 아니면 사용자 등록 진행
4. Security의 인증/인가 여부를 판단하기위한 PrincipalUser 객체 리턴

* Feat: 다양한 OAuth2 서비스 제공자를 추가하여도 기존 코드를 변경하지 않게함으로서 확장성을 높이기위해, 전략 패턴 및 책임 연쇄 패턴 활용

* Chore: OAuth2 시연을 위해 타임리프 템플릿 의존성 추가

* Feat: 소셜 로그인 시연용 html 렌더링을 위해, 기존에 시큐리티가 막고있는 정적 리소스의 접근들 중, 화면 렌더링에 필요한 자원들만 허용

* Refactor: 스프링 시큐리티의 권장사항으로서, 정적 자원 접근에 대한 관리는 HttpSecurity 필터체인에게 위임하도록 한다.

* Feat: 최초 소셜 로그인시 계정 등록에 필요한 orm 새로 구성

* Refactor: 사용자 등록 혹은 로그인에 필요한 컬럼 스키마에 반영

* Feat: OAuth2 소셜 로그인 시연에 필요한 컨트롤러 및 웰컴 페이지 구현

* Feat: 소셜 로그인으로 받은 인가 정보를 매핑하기 위한 CustomAuthorityMapper 구현 및 적용

* Feat: 기본 oauth2 로그인 페이지를 시연용으로 제작한 로그인 페이지로 전환시키기 위해, LoginController와 SecurityConfig 설정 적용

* Fix: OAuth2 소셜 로그인 시연하던 중에 발생한 null 관련 오류 수정

* Feat: kakao 소셜 로그인 추가
  • Loading branch information
wanniDev authored Jun 21, 2024
1 parent 891ab7f commit 0752b45
Show file tree
Hide file tree
Showing 58 changed files with 4,445 additions and 31 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,6 @@ out/
.kotlin

### log ###
logs/
logs/

/src/main/docker/.env
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ dependencies {

// security
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")

// thymeleaf
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")

// testcontainers
testImplementation("org.testcontainers:junit-jupiter")
Expand Down
5 changes: 2 additions & 3 deletions src/main/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ services:
app:
container_name: ticketaka-dev
image: ticketaka-dev
environment:
- JAVA_OPTS=-Xmx1200m -Xms1200m
- SPRING_PROFILES_ACTIVE=dev
env_file:
- .env
ports:
- 8080:8080

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.ticketaka.api.common.infrastructure.security

import com.fasterxml.jackson.databind.ObjectMapper
import io.ticketaka.api.common.ApiError
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 DefaultAccessDeniedHandler(
private val objectMapper: ObjectMapper,
) : AccessDeniedHandler {
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException?,
) {
response.status = HttpServletResponse.SC_FORBIDDEN
val outputStream = response.outputStream
objectMapper
.writeValue(outputStream, ApiError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다."))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.ticketaka.api.common.infrastructure.security

import com.fasterxml.jackson.databind.ObjectMapper
import io.ticketaka.api.common.ApiError
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 DefaultAuthenticationEntryPoint(
private val objectMapper: ObjectMapper,
) : AuthenticationEntryPoint {
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException?,
) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.setHeader("content-type", "application/json;charset=utf8")
val outputStream = response.outputStream
objectMapper
.writeValue(outputStream, ApiError(HttpServletResponse.SC_UNAUTHORIZED, "인증되지 않은 사용자입니다."))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package io.ticketaka.api.common.infrastructure.security

import io.ticketaka.api.user.application.CustomOAuth2UserService
import io.ticketaka.api.user.application.CustomOidcUserService
import io.ticketaka.api.user.infrastructure.CustomAuthorityMapper
import io.ticketaka.api.user.infrastructure.jwt.JwtAuthenticationFilter
import io.ticketaka.api.user.infrastructure.jwt.JwtExceptionFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.access.ExceptionTranslationFilter
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity(debug = true)
@EnableMethodSecurity
class SecurityConfig(
private val accessDeniedHandler: AccessDeniedHandler,
private val authenticationEntryPoint: AuthenticationEntryPoint,
private val customOAuth2UserService: CustomOAuth2UserService,
private val customOidcUserService: CustomOidcUserService,
private val jwtExceptionFilter: JwtExceptionFilter,
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
) {
/*
*
Security filter chain: [
DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter
HeaderWriterFilter
CorsFilter
LogoutFilter
OAuth2AuthorizationRequestRedirectFilter
OAuth2LoginAuthenticationFilter
JwtAuthenticationFilter
DefaultLoginPageGeneratingFilter
DefaultLogoutPageGeneratingFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
SessionManagementFilter
JwtExceptionFilter
ExceptionTranslationFilter
AuthorizationFilter
]
* */
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
// .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { it.anyRequest().permitAll() } // TODO oauth 구현이후 endpoint별 권한 설정 필요
.formLogin {
it
.loginPage("/login")
.loginProcessingUrl("/loginProc")
.defaultSuccessUrl("/")
.permitAll()
}.oauth2Login { oauth2LoginCustomizer ->
oauth2LoginCustomizer.userInfoEndpoint { userInfoEndpointConfig ->
userInfoEndpointConfig
.userService(customOAuth2UserService)
// .oidcUserService(null)
}
}.exceptionHandling { it.authenticationEntryPoint(LoginUrlAuthenticationEntryPoint("/login")) }
.logout { it.logoutSuccessUrl("/") }
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
.addFilterBefore(jwtExceptionFilter, ExceptionTranslationFilter::class.java)
return http.build()
}

@Bean
fun exceptionTranslationFilter(): ExceptionTranslationFilter {
val filter = ExceptionTranslationFilter(authenticationEntryPoint)
filter.setAccessDeniedHandler(accessDeniedHandler)
return filter
}

@Bean
fun customAuthorityMapper(): GrantedAuthoritiesMapper = CustomAuthorityMapper()
}
4 changes: 2 additions & 2 deletions src/main/kotlin/io/ticketaka/api/point/domain/PointHistory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import java.time.LocalDateTime
@Table(name = "point_histories")
class PointHistory(
@Id
val id: Long = 0,
val id: Long,
@Enumerated(EnumType.STRING)
val transactionType: TransactionType,
val userId: Long,
Expand All @@ -31,7 +31,7 @@ class PointHistory(
private set

@Column(nullable = false)
var updatedAt: LocalDateTime? = null
var updatedAt: LocalDateTime? = LocalDateTime.now()
private set

@PreUpdate
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.ticketaka.api.user.application

import io.ticketaka.api.user.domain.ProviderUser
import io.ticketaka.api.user.domain.User
import io.ticketaka.api.user.domain.UserRepository
import io.ticketaka.api.user.infrastructure.oauth2.PrincipalUser
import io.ticketaka.api.user.infrastructure.oauth2.ProviderUserRequest
import io.ticketaka.api.user.infrastructure.oauth2.converter.ProviderUserConverter
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.stereotype.Service

@Service
class CustomOAuth2UserService(
private val userRepository: UserRepository,
private val providerUserConverter: ProviderUserConverter<ProviderUserRequest, ProviderUser>,
) : OAuth2UserService<OAuth2UserRequest, OAuth2User> {
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
val clientRegistration = userRequest.clientRegistration
val oAuth2UserService = DefaultOAuth2UserService()
val oAuth2User = oAuth2UserService.loadUser(userRequest)

val providerUserRequest = ProviderUserRequest(clientRegistration, oAuth2User)
val providerUser =
providerUserConverter.convert(providerUserRequest)
?: throw IllegalArgumentException("Not supported provider")

registerIfAbsent(providerUser)

return PrincipalUser(providerUser)
}

private fun registerIfAbsent(providerUser: ProviderUser) {
val user = userRepository.findByEmail(providerUser.getEmail())
if (user == null) {
userRepository.save(User.newInstance(providerUser.getEmail()))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.ticketaka.api.user.application

import org.springframework.stereotype.Service

@Service
class CustomOidcUserService
7 changes: 7 additions & 0 deletions src/main/kotlin/io/ticketaka/api/user/domain/Attributes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.ticketaka.api.user.domain

data class Attributes(
val mainAttributes: Map<String, Any>,
val subAttributes: Map<String, Any> = emptyMap(),
val otherAttributes: Map<String, Any> = emptyMap(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.ticketaka.api.user.domain

data class AuthenticatedUser(
val userId: Long,
val roles: Set<Role>,
)
19 changes: 19 additions & 0 deletions src/main/kotlin/io/ticketaka/api/user/domain/ProviderUser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.ticketaka.api.user.domain

import org.springframework.security.core.GrantedAuthority

interface ProviderUser {
fun getId(): String

fun getUsername(): String

fun getPassword(): String

fun getEmail(): String

fun getProvider(): String

fun getRoles(): List<GrantedAuthority>

fun getAttributes(): Map<String, Any>
}
35 changes: 24 additions & 11 deletions src/main/kotlin/io/ticketaka/api/user/domain/User.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package io.ticketaka.api.user.domain

import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
import io.ticketaka.api.point.domain.Point
import jakarta.persistence.Column
import jakarta.persistence.ElementCollection
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.PostLoad
import jakarta.persistence.PrePersist
Expand All @@ -18,17 +23,17 @@ class User protected constructor(
@Id
val id: Long,
var pointId: Long,
val email: String,
@ElementCollection(fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
var roles: MutableSet<Role> = hashSetOf(Role.USER),
) : Persistable<Long> {
@Transient
private var isNew = true

override fun isNew(): Boolean {
return isNew
}
override fun isNew(): Boolean = isNew

override fun getId(): Long {
return id
}
override fun getId(): Long = id

@PrePersist
@PostLoad
Expand All @@ -41,7 +46,7 @@ class User protected constructor(
private set

@Column(nullable = false)
var updatedAt: LocalDateTime? = null
var updatedAt: LocalDateTime? = LocalDateTime.now()
private set

@PreUpdate
Expand All @@ -50,12 +55,19 @@ class User protected constructor(
}

companion object {
fun newInstance(pointId: Long): User {
return User(
fun newInstance(pointId: Long): User =
User(
id = TsIdKeyGenerator.nextLong(),
email = "",
pointId = pointId,
)
}

fun newInstance(email: String): User =
User(
id = TsIdKeyGenerator.nextLong(),
email = email,
pointId = Point.newInstance().getId(),
)
}

override fun equals(other: Any?): Boolean {
Expand All @@ -65,13 +77,14 @@ class User protected constructor(
other as User

if (id != other.id) return false
if (pointId != other.pointId) return false

return true
}

override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + id.hashCode()
result = 31 * result + pointId.hashCode()
return result
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package io.ticketaka.api.user.domain

interface UserRepository {
fun save(user: User): User

fun findById(id: Long): User?

fun findByEmail(email: String): User?
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package io.ticketaka.api.user.domain.token

interface TokenExtractor {
fun extract(payload: String): String
fun extract(payload: String?): String
}
Loading

0 comments on commit 0752b45

Please sign in to comment.